diff --git a/book/01-first-module.md b/book/01-first-module.md index f682abe..f9018b6 100644 --- a/book/01-first-module.md +++ b/book/01-first-module.md @@ -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 diff --git a/book/02-website-module.md b/book/02-website-module.md index f73db4d..3555dde 100644 --- a/book/02-website-module.md +++ b/book/02-website-module.md @@ -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 diff --git a/book/05-events.md b/book/05-events.md index d0747a9..4729db2 100644 --- a/book/05-events.md +++ b/book/05-events.md @@ -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 `" 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. diff --git a/ci/core-ref.json b/ci/core-ref.json index c6f8b82..a735702 100644 --- a/ci/core-ref.json +++ b/ci/core-ref.json @@ -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:", diff --git a/template/module.json b/template/module.json index 3301201..489eae5 100644 --- a/template/module.json +++ b/template/module.json @@ -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",