docs(kit): pin core 1.17.0; teach 1.13.0 to 1.17.0 and one module per site #15

Merged
whitlocktech merged 1 commits from docs/core-1.17.0 into main 2026-10-23 02:07:44 -05:00
5 changed files with 132 additions and 6 deletions

View File

@@ -135,7 +135,7 @@ whether it is a directory; a symlink answers no, and your module is skipped in
complete silence. This is the single most common way a first install appears to do
nothing at all.
Two more things that look like your module failing and are not:
Three more things that look like your module failing and are not:
- If core is running in a container, your files have to be on the volume core sees
— `MODULES_DIR` (`/app/modules` under the shipped Compose file), not the
@@ -143,6 +143,14 @@ Two more things that look like your module failing and are not:
- A core with a fresh database boots in **maintenance mode**, and public module
pages sit behind the same maintenance gate core's own do. Your page will look
broken while the site is not live yet.
- **A site runs one game module.** If the modules directory already holds one, an
Ultima Online or Rust site's for example, only the directory whose name sorts
first starts. Every other is refused before any of its code is read, and its
reason on Admin → Modules names both modules. `examplegame` sorts ahead of `rust`
and `uo`, so on such a core yours starts and the site's own module does not. Try
your module on a core of your own, with nothing else in its modules directory.
This is [`MODULE_SYSTEM.md` §2.5][system]; the installer refuses a second module
too, so the rule only surprises someone copying directories by hand.
## What you should see
@@ -270,6 +278,7 @@ what `ctx` hands you and why it is handed rather than imported, what each
module must never do.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
[renamecheck]: ../scripts/checkRenameSites.js
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md

View File

@@ -147,9 +147,10 @@ statement of your dependencies, and it makes a test double for it — see
## What you register
Twelve calls — ten registrations and the two lifecycle hooks — all synchronous,
all listed in [§2.4][api]. What is worth knowing is not their signatures but the
model behind them.
Nineteen calls, all synchronous and all listed in [§2.4][api]: fifteen
registrations, the two lifecycle hooks, and the two identity-link hooks. Five of the
registrations are the event system's, and [chapter 5](05-events.md) has those. What
is worth knowing is not their signatures but the model behind them.
**Every call stages; nothing is committed until your whole module is known good.**
The shape of a claim is checked at the call, so a malformed one throws with your
@@ -505,6 +506,37 @@ warm-up it never finished, and handing it a half-built world to tear down is wor
than not closing cleanly. A module with no hooks at all still reaches `started`:
having nothing to warm up is not the same as never having started.
### When a player links a sign-in identity
If your game has its own idea of an account, a player's Steam id for instance, you
may want to know when somebody links that identity to their site account
(`MODULE_API_VERSION` 1.17.0). Linking stays core's. Core verifies the identity
with its provider and writes the link; your module may read what core verified,
refuse a link, and hear one, and nothing more.
```js
api.onIdentityLinking(async ({ userId, provider, subject }) => ({ ok: true }))
api.onIdentityLinked(async ({ userId, provider, subject, displayName }) => {})
// and, from anywhere: ctx.identities.list(userId, provider)
```
- **`onIdentityLinking` is asked before the link is written**, for every provider,
so filter on `provider` yourself. Answer `{ ok: false, reason: 'in_use' }` when
the identity already belongs to somebody else in your game; the person reads
core's own *"already linked to another user"*. **It fails closed**: a throw, or no
answer within 5 s, refuses the link. That is the right direction for a check whose
whole job is to stop one game account landing on two people.
- **Your `message` is logged, never shown.** Module text on the page would let
anybody craft a link that prints a sentence of their choosing on the account
screen.
- **`onIdentityLinked` is told after the row exists**, and is awaited for 5 s so
the page the person lands on already shows what you made of it. Its failure is
logged and the link stands, so write it to tolerate a race: your own record may be
taken between the check and this call, and "theirs stands" is the answer.
- **There is no unlink hook.** Unlinking removes only the sign-in, and a record your
module made from it is yours to keep or remove.
- Neither hook can mint a session or change who is signed in.
## The schema fragment
`template/server/db/schema.sql` is your tables. It is replayed **in full, at every

View File

@@ -89,6 +89,7 @@ api.registerEventLeases([{
id: 'examplegame.rate.gather',
label: 'Gather rate',
type: 'float', min: 0.5, max: 5,
example: 2, // optional, 1.13.0: the value box's placeholder
maxDurationMs: 48 * 60 * 60 * 1000,
async read() { /* the live baseline */ },
@@ -167,6 +168,13 @@ second code path is a dry run of the second path. Validate everything you can
reach without writing, then stop. Answering `{ ok: true }` unconditionally makes
the dry run worthless in the one situation it exists for.
**A dry run may also advise** (MODULE_API 1.16.0). `{ ok: true, detail: { warnings:
["…"] } }` shows each line to the author as a warning on that step, and it never
fails the dry run. Use it for what is true of the world now and may not be when the
run starts: *"the spot is refused now: a player is within 50 m"*. Strings only, at
most ten per step, each cut at 500 characters. What would make the step fail
whenever it ran is not advice; answer that as a refusal.
**`progress` is read by strangers, so it answers for one reader** (MODULE_API 1.12.0).
While a run is live, core asks it how its step stands and puts the answer on the
event's public page and in the app.
@@ -189,6 +197,48 @@ unreconstructable afterwards by anybody who did not write the action.
another module) registered. A source that refuses degrades its field to free text
with a warning and never blocks the form — so resolve from live data and return
`[]` on failure, rather than defending with a hardcoded list that will be wrong.
A row may carry a `note` (1.13.0), a sentence the editor shows under the field
while that row is chosen. It is advice about the value; nothing on the save path
reads it.
Two more things a param can say, both for the editor's sake:
- **`duration: 'minutes'`** (1.13.0), on an `int` or `float` that is a length of
time. The editor then knows the step holds something for that long, and sees to
it that the phase it sits in lasts at least as long. Declare it on every param
that is a duration; the editor has no other way to tell.
- **`multiple: true`** (1.14.0), with an optional `max` (1 to 100, 20 by default),
on a `string` or `int`. The author picks several values, and `perform()` and
`cost()` receive an array. A bare value arrives as a list of one, so a spec
written by hand still works. Price the whole list in `cost()`: three kits given
are three gives.
## Templates: a shortcut past the editor
A fifth declaration, `api.registerEventTemplates` (MODULE_API 1.13.0), gives an
author a named starting point for a new event: "A kit weekend", built from a kit
and two dates.
```js
api.registerEventTemplates([{
id: 'examplegame.beacon-night',
label: 'Beacon night',
description: 'Light the beacons at dusk, put them out at dawn.',
inputs: [ /* params, as an action's are, at most 8 */ ],
async build({ inputs }) { return { title, summary, spec: { phases } } },
}])
```
**A template is never a second way to make an event.** What `build()` returns is
checked as a spec and opened as an ordinary unsaved draft, and the author saves it
through the same route and the same checks as one they typed. So a template cannot
make an event the save path would refuse, and nothing about it is stored: changing
or removing one touches no event it ever made. When the inputs make no sense,
answer `{ ok: false, error }` with a sentence for the author; anything you throw
stays in the log.
Build one last, once your actions and leases are right. It is a convenience over
them, never a replacement for them.
---
@@ -233,6 +283,13 @@ silence and the operator sees `"<action id> refused"`. Writing this chapter's
template is how that was found — its first draft used `detail`, and every refusal
it produced was anonymous.
**A retry may say when** (MODULE_API 1.15.0). Core retries a failed step after a
flat minute. If your game knows better, as with a thing that is warming up for ten
minutes, answer `{ ok: false, retry: true, retryAfterMs }`. Core clamps it to one
to thirty minutes rather than refusing it, and it still counts as one attempt of
the step's few: it changes *when*, never *how many*. So a hint is not a way to wait
for something that may take an hour; refuse that, and say why in `error`.
## 2. Pass the idempotency key through, and put it on a command rather than a question
Core hands `perform()` an `idempotencyKey` derived from the step's identity — never
@@ -315,6 +372,13 @@ time is up), call `ctx.events.expired({ kind, ref })` when it does. Core then ma
row `expired`: finished as planned, never taken back at teardown. Left to `reconcile`,
the same thing would turn up `orphaned` and read as something that went wrong.
**And report its deadline when you make it.** A resource in `perform()`'s answer may
carry `until`, the instant the game will end it by itself (an ISO string or epoch
milliseconds). After an outage, core's reconcile can run before the game's own "it
ended" has reached it. A resource gone past its `until` is then filed `expired`, as
`ctx.events.expired()` would have filed it, instead of `orphaned`. Without `until`,
the same outage turns every timed thing your event made into an operator's alarm.
**You say WHEN to reconcile, because core cannot.** Core has no concept of the game
being up — it sees `{ ok: false, retry: true }` and cannot tell a wedged sidecar
from a game that rebooted and lost everything an event made. So it asks once, at
@@ -379,6 +443,8 @@ Four things a second module's author reaches for and should not.
Its absence is merely a lower standard rather than a broken promise.
7. **`progress`**, if a visitor would want to watch the step happen. It is the only
one an outsider reads, so build it once the ledger it counts from is right.
8. **A template**, if authors keep building the same event by hand. It saves them
typing and adds no power, so it comes last.
Then read your own `revert` again, and ask what it answers when the game is down.

View File

@@ -1,10 +1,29 @@
{
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"branch": "main",
"ref": "0a37a44164ab107584b918e656651452b50bd4f3",
"ref": "6890a74c7c4ad5690509e8c163e477d7f7b399e7",
"why": [
"The core this kit is written against, pinned to a commit rather than a branch.",
"",
"2026-10-23: moved from 0a37a44 (1.12.0) to 6890a74 (1.17.0, website#220,",
"the cutover that brought Steam sign-in and the Rust last fixes to `main`),",
"past 1.13.0 to 1.16.0, which reached `main` together on 2026-10-22. The",
"chapters were read again for all five versions:",
"- 1.13.0: templates (chapter 5 gains a section), a param's duration, a",
" lease's example, an option row's note;",
"- 1.14.0: a param's multiple and max;",
"- 1.15.0: a retry's retryAfterMs;",
"- 1.16.0: a dry run's detail.warnings;",
"- 1.17.0: onIdentityLinking / onIdentityLinked and ctx.identities.list",
" (chapter 2 gains a section, and its call count is corrected to nineteen).",
"Two core changes in the same cutover carried no version bump, and both are",
"taught: a site runs ONE game module, which the loader now enforces at boot",
"(chapter 1, the reader most likely to be surprised by it), and a resource's",
"`until` is read at reconcile (chapter 5). The template declares none of the",
"new members, so nothing it does changed meaning; module.json's coreApi",
"becomes ^1.17.0 for the equality check. The real core run was repeated at",
"this ref, with the template as the only module in the directory.",
"",
"2026-10-06: moved from 655fbf3f (1.10.0) to 0a37a44 (1.12.0, website#210),",
"past 1.11.0 (website#209), which this pin had skipped. Chapter 5 was read again",
"for both versions and gained two paragraphs:",

View File

@@ -2,7 +2,7 @@
"id": "examplegame",
"name": "Example Game",
"version": "0.1.0",
"coreApi": "^1.12.0",
"coreApi": "^1.17.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",