Compare commits
10 Commits
2b84d79bd1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c24443f721 | |||
| 3f5cd72ed6 | |||
| be767776ac | |||
| 36f29a9cac | |||
| 045679bbbc | |||
| 00f5234f97 | |||
| 0297f81ae2 | |||
| 2f018999c3 | |||
| 50e28dc3bd | |||
| 5b3f5a5d54 |
@@ -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
|
complete silence. This is the single most common way a first install appears to do
|
||||||
nothing at all.
|
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
|
- 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
|
— `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
|
- 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
|
pages sit behind the same maintenance gate core's own do. Your page will look
|
||||||
broken while the site is not live yet.
|
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
|
## 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.
|
module must never do.
|
||||||
|
|
||||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
[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
|
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
|
||||||
[renamecheck]: ../scripts/checkRenameSites.js
|
[renamecheck]: ../scripts/checkRenameSites.js
|
||||||
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md
|
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md
|
||||||
|
|||||||
@@ -147,9 +147,11 @@ statement of your dependencies, and it makes a test double for it — see
|
|||||||
|
|
||||||
## What you register
|
## What you register
|
||||||
|
|
||||||
Twelve calls — ten registrations and the two lifecycle hooks — all synchronous,
|
Twenty-three calls, all synchronous and all listed in [§2.4][api]: seventeen
|
||||||
all listed in [§2.4][api]. What is worth knowing is not their signatures but the
|
registrations, the two lifecycle hooks, the two identity-link hooks, the demo's
|
||||||
model behind them.
|
seed hook, and the account-deletion hook. 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.**
|
**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
|
The shape of a claim is checked at the call, so a malformed one throws with your
|
||||||
@@ -505,6 +507,164 @@ 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`:
|
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.
|
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.
|
||||||
|
|
||||||
|
### When a member deletes their account
|
||||||
|
|
||||||
|
A member can delete their own site account, and staff can delete one from the
|
||||||
|
admin panel (`MODULE_API_VERSION` 1.20.0). Core removes the row, and every table
|
||||||
|
with a foreign key to it follows: `ON DELETE CASCADE` for what was theirs alone,
|
||||||
|
`SET NULL` for what they made that others still read. Your module hears about it
|
||||||
|
once, first:
|
||||||
|
|
||||||
|
```js
|
||||||
|
api.onUserDeleting(async ({ userId, deletedBy }) => {}) // deletedBy: 'self' | 'staff'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **It runs before the row goes**, so you can still read what the person holds.
|
||||||
|
- **A cascade is not an unlink.** If your game holds anything for the person, such
|
||||||
|
as a link it reports back on every login or a grant it enforces, undo it here
|
||||||
|
through your module's own unlink, the same call a player's own unlink makes. That
|
||||||
|
way the game sees exactly what it would see if they had unlinked by hand. Clean up
|
||||||
|
any data you keep without a foreign key here too.
|
||||||
|
- **It cannot stop the deletion.** A throw is logged, and so is no answer within
|
||||||
|
10 s, and the account is deleted anyway, because a person who asks to be deleted
|
||||||
|
must be. So a module that talks to a game needs **a record of what is left and a
|
||||||
|
retry**: write down what you are about to undo *before* the first call, keep what
|
||||||
|
the game could not take (it was down, or it refused), and try again when the game
|
||||||
|
comes back. That record cannot have a foreign key to `users`, because it outlives
|
||||||
|
the user it names.
|
||||||
|
- **Your hook keeps running past the 10 s.** Core stops waiting, not your code, so
|
||||||
|
the user may already be gone when your last call finishes. Anything you write
|
||||||
|
afterwards must not need the row: an activity entry, for instance, names the id in
|
||||||
|
its `detail`, not its `userId`.
|
||||||
|
- **Every module that holds per-person game state should register it.** Declaring it
|
||||||
|
needs `coreApi` `^1.20.0`, because an older core has no `api.onUserDeleting`, and
|
||||||
|
calling a missing function throws during `register()`.
|
||||||
|
|
||||||
|
### When members report, hide or block what your game shows
|
||||||
|
|
||||||
|
If your module shows what players wrote, such as chat from the game, members can
|
||||||
|
report it, hide it for themselves, and block whoever wrote it, and staff can hide
|
||||||
|
it for everyone (`MODULE_API_VERSION` 1.21.0, [`USER_SAFETY.md`][safety]). Core
|
||||||
|
keeps the Reports queue and each member's blocks and hides. You keep the content,
|
||||||
|
and you tell core three things about it:
|
||||||
|
|
||||||
|
```js
|
||||||
|
api.registerReportTargets([{ id: 'mygame.chat', label: 'Chat line', snapshot, hide, unhide }])
|
||||||
|
api.registerGameIdentities([{ kind: 'steam', label: 'Steam account', ownersOf }])
|
||||||
|
// and, from your own per-reader routes: ctx.safety.forViewer(userId), ctx.safety.staffAmong(userIds)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`snapshot({ targetId, viewer })` answers for that reader, and `null` covers two
|
||||||
|
cases.** An item that is gone and an item they could not have seen get the same
|
||||||
|
`null`, so core gives them the same 404. Otherwise a report or a block would tell
|
||||||
|
anybody who said line 4410 on a server whose player list is hidden from them. Run
|
||||||
|
the same visibility check your page runs.
|
||||||
|
- **Name the author by their site account when they have one.** `author.userId` is
|
||||||
|
the linked account and `author.identity` is the game id. `ownersOf(ids)` answers
|
||||||
|
which ids are linked. A block then lands on the account, and only an unlinked id
|
||||||
|
is blocked as itself. Core rewrites that block as an account block once the id
|
||||||
|
links, so you never have to.
|
||||||
|
- **Filter on the server, in your query.** A route that shows the content to
|
||||||
|
signed-in readers calls `ctx.safety.forViewer(userId)` and leaves out what that
|
||||||
|
reader blocked or hid. Do it in the `WHERE` clause, so a page of 100 is still 100
|
||||||
|
long, and keep the route per-viewer (`Cache-Control: private`), or a shared cache
|
||||||
|
hands one reader's filter to the next. If your page offers *Block*, mark each item
|
||||||
|
with whether it can be blocked. Use `ctx.safety.staffAmong` for that, because
|
||||||
|
staff cannot be blocked.
|
||||||
|
- **Hide for everyone is a flag you keep.** `hide` is called from core's queue.
|
||||||
|
Leave the item out for everyone but staff, and show it to staff marked, with an
|
||||||
|
undo where it lives. Core never calls `unhide` itself.
|
||||||
|
- **Each call has 5 s.** A module that does not answer is a 503 to the member,
|
||||||
|
never a silent success. Declaring any of it needs `coreApi` `^1.21.0`.
|
||||||
|
|
||||||
|
Module-Rust's `server/safety.js` is a complete worked example: chat lines, Steam
|
||||||
|
ids and the feed's own filter.
|
||||||
|
|
||||||
|
### On a public demo site
|
||||||
|
|
||||||
|
A site can run as a public demo (`MODULE_API_VERSION` 1.18.0). Anybody signs in with
|
||||||
|
published logins, nothing destructive is allowed, and the site is wiped and seeded
|
||||||
|
again every couple of hours. It is the same image with one flag set,
|
||||||
|
`DEMO_MODE=true`. Core locks its own routes; yours are yours to declare, and three
|
||||||
|
members are for that. With the flag off none of them changes anything, except that
|
||||||
|
`demoLocks` is still validated.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"demoLocks": [
|
||||||
|
{ "method": "PUT", "path": "/admin/mygame/servers/:id" },
|
||||||
|
{ "method": "DELETE", "path": "/admin/mygame/servers/:id" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
(The template has no write routes, so this is a shape rather than one of its files.)
|
||||||
|
|
||||||
|
- **`demoLocks` in `module.json` lists your write routes that the demo refuses.**
|
||||||
|
Core answers a match `403 { code: 'DEMO_LOCKED', message }` before your handler
|
||||||
|
runs, the same body it gives for its own locks, so the site's one toast explains
|
||||||
|
it. Writes only (`POST`, `PUT`, `PATCH`, `DELETE`), in your routers' own `:param`
|
||||||
|
syntax with an optional trailing `/*`, and under one of your declared `mounts`.
|
||||||
|
**It is validated on every boot, demo or not**, so a wrong entry fails on your
|
||||||
|
machine rather than only on the demo.
|
||||||
|
- **Two refusals reach your routes without being listed.** On a demo, core refuses
|
||||||
|
any request with a multipart body, and any write whose JSON body carries a
|
||||||
|
`data:` URI in any string (a pasted image is an upload by another name), with the
|
||||||
|
same `403` body. An upload route of yours is locked on a demo whether or not
|
||||||
|
`demoLocks` names it.
|
||||||
|
- **`ctx.demoMode` is for a lock a route list cannot express**, one that depends on
|
||||||
|
what is in the request rather than which URL it went to. The Rust module refuses a
|
||||||
|
console command by its first word, and a plugin's config file by the plugin that
|
||||||
|
owns it. Check it in your handler and answer with the same `403` body. It is also
|
||||||
|
how you hide the rare read that must never be shown on a demo, such as a file
|
||||||
|
holding credentials; `demoLocks` takes writes only.
|
||||||
|
- **`api.onDemoSeed(fn)` writes your rows into the demo's fresh database.** The seed
|
||||||
|
calls `fn(data, { accounts })`: `data` is your section of the fixture
|
||||||
|
(`modules.<id>`, with every `"$env:NAME"` string already replaced) and `accounts`
|
||||||
|
maps each demo username to its user id. Core never writes your tables. **Upsert,
|
||||||
|
never insert**: the seed may run more than once on one database, and core seeds
|
||||||
|
its own content only the first time but calls your hook on every run. A fixture section for a module with no hook fails the seed, and so does a
|
||||||
|
hook that throws.
|
||||||
|
- **Lock at the server, never in the UI.** A request sent with `curl`, or from an
|
||||||
|
old app build, must be refused exactly like a click. Your pages may explain a lock;
|
||||||
|
they must not hide or disable a control as the way of locking it.
|
||||||
|
- **Declaring `demoLocks` needs `coreApi` `^1.18.0`**, because an older core rejects
|
||||||
|
the unknown key. Using only the other two should declare it as well: an older core
|
||||||
|
has no `onDemoSeed`, and its `ctx.demoMode` is `undefined`, which reads as "not a
|
||||||
|
demo".
|
||||||
|
|
||||||
|
An event step the demo should refuse when it is saved is
|
||||||
|
[chapter 5](05-events.md)'s `demoRefuses`.
|
||||||
|
|
||||||
## The schema fragment
|
## The schema fragment
|
||||||
|
|
||||||
`template/server/db/schema.sql` is your tables. It is replayed **in full, at every
|
`template/server/db/schema.sql` is your tables. It is replayed **in full, at every
|
||||||
@@ -819,3 +979,4 @@ the next chapter is for.
|
|||||||
|
|
||||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md
|
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md
|
||||||
|
[safety]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/USER_SAFETY.md
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ api.registerEventLeases([{
|
|||||||
id: 'examplegame.rate.gather',
|
id: 'examplegame.rate.gather',
|
||||||
label: 'Gather rate',
|
label: 'Gather rate',
|
||||||
type: 'float', min: 0.5, max: 5,
|
type: 'float', min: 0.5, max: 5,
|
||||||
|
example: 2, // optional, 1.13.0: the value box's placeholder
|
||||||
maxDurationMs: 48 * 60 * 60 * 1000,
|
maxDurationMs: 48 * 60 * 60 * 1000,
|
||||||
|
|
||||||
async read() { /* the live baseline */ },
|
async read() { /* the live baseline */ },
|
||||||
@@ -149,6 +150,7 @@ api.registerEventActions([{
|
|||||||
async revert({ runId, resources, idempotencyKey }) {}, // required iff 'ledger'
|
async revert({ runId, resources, idempotencyKey }) {}, // required iff 'ledger'
|
||||||
async reconcile({ runId, resources }) {}, // optional
|
async reconcile({ runId, resources }) {}, // optional
|
||||||
async progress({ runId, stepId, idempotencyKey, params, resources, viewer }) {}, // optional, 1.12.0
|
async progress({ runId, stepId, idempotencyKey, params, resources, viewer }) {}, // optional, 1.12.0
|
||||||
|
async demoRefuses(params) {}, // optional, 1.19.0
|
||||||
}])
|
}])
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -167,6 +169,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
|
reach without writing, then stop. Answering `{ ok: true }` unconditionally makes
|
||||||
the dry run worthless in the one situation it exists for.
|
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).
|
**`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
|
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.
|
event's public page and in the app.
|
||||||
@@ -181,6 +190,22 @@ event's public page and in the app.
|
|||||||
already cache, not from a fresh round trip to the game per reader.
|
already cache, not from a fresh round trip to the game per reader.
|
||||||
- **Failure.** A throw or a late answer costs a line, never the page.
|
- **Failure.** A throw or a late answer costs a line, never the page.
|
||||||
|
|
||||||
|
**`demoRefuses` keeps a step off a public demo** (MODULE_API 1.19.0). A demo site
|
||||||
|
([chapter 2](02-website-module.md)) lets anybody author an event, and some steps must
|
||||||
|
never be saved there: the Rust module refuses a console command that would stop the
|
||||||
|
server. Core asks `demoRefuses(params)` only with `DEMO_MODE` on, for every step,
|
||||||
|
when a definition is created, saved or published, with the step's checked params.
|
||||||
|
|
||||||
|
- **The answer.** A sentence refuses the whole write with `403 DEMO_LOCKED`, and the
|
||||||
|
author reads it as written, so phrase it for them: *"quit" is turned off on the
|
||||||
|
demo site.* `null` lets the step through.
|
||||||
|
- **It is a pure check.** No database, no game, nothing changed. A throw refuses too,
|
||||||
|
with core's own sentence, because a lock that cannot decide fails closed.
|
||||||
|
- **Check again in `perform()`**, behind `ctx.demoMode`. A version published before
|
||||||
|
your list grew is never asked again, so the save cannot be the only lock.
|
||||||
|
- **It needs `coreApi` `^1.19.0`.** An older core never asks it, and the save goes
|
||||||
|
through without a word.
|
||||||
|
|
||||||
**`example` is required on every param, optional ones included.** It is the
|
**`example` is required on every param, optional ones included.** It is the
|
||||||
authoring form's placeholder. It is one word at declaration time and it is
|
authoring form's placeholder. It is one word at declaration time and it is
|
||||||
unreconstructable afterwards by anybody who did not write the action.
|
unreconstructable afterwards by anybody who did not write the action.
|
||||||
@@ -189,6 +214,48 @@ unreconstructable afterwards by anybody who did not write the action.
|
|||||||
another module) registered. A source that refuses degrades its field to free text
|
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
|
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.
|
`[]` 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 +300,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
|
template is how that was found — its first draft used `detail`, and every refusal
|
||||||
it produced was anonymous.
|
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
|
## 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
|
Core hands `perform()` an `idempotencyKey` derived from the step's identity — never
|
||||||
@@ -315,6 +389,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`,
|
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.
|
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
|
**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
|
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
|
from a game that rebooted and lost everything an event made. So it asks once, at
|
||||||
@@ -379,6 +460,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.
|
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
|
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.
|
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.
|
Then read your own `revert` again, and ask what it answers when the game is down.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,66 @@
|
|||||||
{
|
{
|
||||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"ref": "0a37a44164ab107584b918e656651452b50bd4f3",
|
"ref": "f13b8fe1fec9dc330a985430b8bd4f67c9ec26c5",
|
||||||
"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.",
|
||||||
"",
|
"",
|
||||||
|
"2026-10-30: moved from 7bd93b8 (1.20.0) to f13b8fe (1.21.0, website#233,",
|
||||||
|
"report, block and hide, USER_SAFETY.md). Chapter 2 was read again:",
|
||||||
|
"- 1.21.0: api.registerReportTargets, api.registerGameIdentities and",
|
||||||
|
" ctx.safety.forViewer / staffAmong (chapter 2 gains a section, and its call",
|
||||||
|
" count becomes twenty-three).",
|
||||||
|
"website#233 is the only core change in the window. The template declares",
|
||||||
|
"none of the new members, so nothing it does changed meaning; module.json's",
|
||||||
|
"coreApi becomes ^1.21.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-30: moved from 69998ec (1.19.0) to 7bd93b8 (1.20.0, website#232,",
|
||||||
|
"account deletion, ACCOUNT_DELETION.md). Chapter 2 was read again:",
|
||||||
|
"- 1.20.0: api.onUserDeleting (chapter 2 gains a section, and its call count",
|
||||||
|
" becomes twenty-one).",
|
||||||
|
"The other core changes in the window (website#225-#231) are the public",
|
||||||
|
"demo's deploy, seed and locks. One reaches a module: website#229 refuses, on",
|
||||||
|
"a demo, any write whose JSON body carries a data: URI, beside the multipart",
|
||||||
|
"refusal 1.18.0 already had, and neither needs listing in demoLocks. Chapter",
|
||||||
|
"2's demo section now says so; it had not taught the multipart one either.",
|
||||||
|
"The template declares none of the new members, so nothing it does changed",
|
||||||
|
"meaning; module.json's coreApi becomes ^1.20.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-26: moved from 6890a74 (1.17.0) to 69998ec (1.19.0, website#224),",
|
||||||
|
"past 1.18.0 (website#223). Both are the public demo (DEMO_MODE.md), and the",
|
||||||
|
"chapters were read again for both versions:",
|
||||||
|
"- 1.18.0: demoLocks in module.json, ctx.demoMode and api.onDemoSeed",
|
||||||
|
" (chapter 2 gains a section, and its call count becomes twenty);",
|
||||||
|
"- 1.19.0: an event action's demoRefuses (chapter 5, a paragraph and a line",
|
||||||
|
" in the action listing).",
|
||||||
|
"The one other core change in the window (website#221) is a wording fix on",
|
||||||
|
"core's Teams panel and nothing a module sees. The template declares none of",
|
||||||
|
"the new members, so nothing it does changed meaning; module.json's coreApi",
|
||||||
|
"becomes ^1.19.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-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),",
|
"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",
|
"past 1.11.0 (website#209), which this pin had skipped. Chapter 5 was read again",
|
||||||
"for both versions and gained two paragraphs:",
|
"for both versions and gained two paragraphs:",
|
||||||
@@ -15,7 +71,7 @@
|
|||||||
"The template declares neither, and nothing it does changed meaning. The real",
|
"The template declares neither, and nothing it does changed meaning. The real",
|
||||||
"core run below was repeated at this ref.",
|
"core run below was repeated at this ref.",
|
||||||
"",
|
"",
|
||||||
"Before that move:"
|
"Before that move:",
|
||||||
"This one is the EVENT SYSTEM cutover, the commit MODULE_API_VERSION 1.10.0",
|
"This one is the EVENT SYSTEM cutover, the commit MODULE_API_VERSION 1.10.0",
|
||||||
"reached `main` on (website#199), and 1.10.0 is what template/module.json",
|
"reached `main` on (website#199), and 1.10.0 is what template/module.json",
|
||||||
"declares. It moved here from 66bb3b9a (1.9.0, the engagement cutover) because",
|
"declares. It moved here from 66bb3b9a (1.9.0, the engagement cutover) because",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "examplegame",
|
"id": "examplegame",
|
||||||
"name": "Example Game",
|
"name": "Example Game",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"coreApi": "^1.12.0",
|
"coreApi": "^1.21.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",
|
||||||
|
|||||||
Reference in New Issue
Block a user