Compare commits

...

8 Commits

Author SHA1 Message Date
e852e5574d Merge pull request 'docs(book): the game host already has the files your site wants (chapter 3 §2b)' (#12) from docs/asset-bridge-p9 into main
Reviewed-on: #12
2026-09-14 22:25:37 +00:00
ad37cade6e docs(book): the game host already has the files your site wants (chapter 3 §2b)
All checks were successful
PR Checks / prose (pull_request) Successful in 16s
PR Checks / template (pull_request) Successful in 36s
The integration kit's share of the Asset Bridge, and the whole of it: one section
in the sidecar chapter, teaching the pattern rather than re-specifying anything.
`docs/link/v8.md` stays normative and is linked out to, as every chapter does.

The problem is general even though our instance of it is not. Most games keep
content on the host that a website wants to show -- sprites, icons, portraits,
localisation tables, map definitions -- and the tempting answer is to make it the
operator's problem: export it on a desktop with a third-party tool, upload the
result, repeat after every patch. It works once and rots immediately.

The four design notes are the ones that cost us real time to learn: content rides
request/reply and never events (a sidecar that persists and broadcasts every
event would write megabytes of sprite into its store and fan it out to every
client); serve one at a time and put "busy" in the protocol so a caller treats it
as flow control; two stages, so the common case -- a restart that changed nothing
-- costs one small round trip; and version your DERIVATION separately from the
protocol, because improving how you read a file changes your bytes while the
file's hash stays put.

Plus the operational note that surprises people: do not import on boot.

Based on `main` rather than `edge` deliberately -- the kit's chapter 5 and the
§2a it follows are on main only, so this section has nowhere to sit on edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 13:09:57 -05:00
7f746aee3d Merge pull request 'chore(ci): pin the kit to the Event System core, and go green (Phase 16b cutover, 5 of 6)' (#11) from chore/events-cutover-repin into main
Reviewed-on: #11
2026-09-10 01:23:59 +00:00
5dc14fa626 chore(ci): pin the kit to the Event System core, and go green (Phase 16b)
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 34s
`ci/core-ref.json` moves from 66bb3b9a (MODULE_API 1.9.0, the engagement
cutover) to 655fbf3f -- the commit 1.10.0 reached `main` on, website#199.

This closes a red `main` rather than only dating the book. Chapter 5 landed in
#10 declaring `coreApi ^1.10.0` while this file still named a 1.9.0 core, and
`checkCoreApi` asserts EQUALITY, so the repo has been red on that check since it
merged. That was deliberate and said so in the PR, but the red belongs to the
cutover window and not to the repo; this is the commit that was always going to
close it, and it could not be written until the events sha existed on `main`.
Same shape Teams phase 11 used.

A pin move is a RUN, not an edit -- the template job checks this number and
tests the template against fakes, and a fake accepts what core refuses. So the
template's real declarations went through core's real registries at this exact
ref: the budget, the option source, the lease and the event action were all
accepted, and `apply()` accepted the set. Nothing else here needed to move;
`template/module.json` has declared ^1.10.0 since #10.

Verified against a core at this ref:

  checkCoreApi         coreApi ^1.10.0 matches the pinned core's 1.10.0
  registry rig         all four declarations accepted, apply() accepted
  template server      82 pass, 0 fail          check:imports OK
  template client      20 pass, 0 fail          check:externals OK, build OK
  prose checks         checkLinks, checkRenameSites, checkChapterPaths all OK
  their own tests      10 pass, 11 pass

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 19:50:40 -05:00
a72b002f75 Merge pull request 'feat(kit): the event contract, taught and built (Phase 15)' (#10) from feature/events-p15-event-contract into main
Reviewed-on: #10
2026-09-08 23:27:03 +00:00
f89044b42e feat(kit): the event contract, taught and built (chapter 5)
Some checks failed
PR Checks / prose (pull_request) Successful in 12s
PR Checks / template (pull_request) Failing after 29s
The fifth chapter, and the template code it teaches out of. Events is the first
thing in the book that goes the other way — chapters 1-4 move data out of the
game and onto a page; an event changes a live world on a schedule, unattended.

**Chapter 5** covers the four declarations (budgets, option sources, leases,
actions), leads with the lease because EVENTS.md §H is right that it is the
primitive that travels and the spawn is the special case, and gives one section
each to the four things that are invisible until an outage: the envelope's
failure default, the idempotency passthrough, recording a resource before
confirming it, and under-declaring `cost`.

**Chapters 3 and 4 gain one section each** for the command plane, because
without them chapter 5 teaches a module to send an idempotency key to a sidecar
the book never told anyone to build a command path in. Both say at the top that
they are skippable until you want chapter 5.

**The template ships one of each declaration**, with `server/sidecarClient.js`
as the near end — a real timeout, a real key passthrough, a simulated transport
in one function marked for replacement. That file is named for the filename
`noGameConnection.test.js` already anticipated, so the test stays green now and
fires correctly the moment `deliver()` becomes a request.

Two things writing it found, both now in the chapter and beside the code:

  * **An idempotency key belongs on a command, never on a question.** The first
    draft keyed every call including the reads; an at-most-once store then
    answers every future read with the first one's reply, forever. The lease
    applied correctly and the module could no longer see it. Hence `ask` and
    `send` as two functions.

  * **A refusal's reason goes in `error`; core reads no other name.** The first
    draft used `detail`, on the strength of the one place EVENTS.md §H mentions
    it, and every refusal it produced was anonymous on the run console.

Proved by running the template's real declarations through core's real registry
at `edge` (all four accepted) and its real envelopes through the real
`events/dispatch.js` classifier.

**CI is RED on `checkCoreApi` and that is the mechanism working.** The template
now declares `coreApi: ^1.10.0` and `ci/core-ref.json` pins the engagement
cutover, where `main` is still 1.9.0. Equality is the check, a bump is meant to
turn this repo red until someone re-reads the chapters, and the pin move rides
in the events cutover (EVENTS_PLAN.md P16) as its own commit. Do not "fix" it.

Refs EVENTS_PLAN.md Phase 15, EVENTS.md §F, MODULE_API.md 1.10.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 18:10:18 -05:00
e9ca759227 Merge pull request 'feat(kit): the engagement contract, taught and built — cutover 5 of 7' (#9) from feat/engagement-contract into main
Reviewed-on: #9
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 18:03:52 +00:00
a8fa524263 feat(kit): the engagement contract, taught and built (cutover 5 of 7)
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 31s
The kit was pinned to website 963d734 -- MODULE_API 1.6.0, the Teams cutover --
and the platform is on 1.9.0. Three registrations and two calls arrived in
between, and a reader building against this book would have found no mention of
any of them: a module can now declare what its game can announce, and never who
is told.

Moving `ci/core-ref.json` is the mechanism for exactly this. The pin is now
66bb3b9a (website `main`, the engagement cutover) and `template/module.json`
declares `^1.9.0`.

What chapter 2 gained, under "Telling core something happened":

  * a TRIGGER is a payload contract, not a notification stream -- the two share
    one id namespace and are constantly confused;
  * `ceiling` is required, has no default, and is a CONTAINMENT tree rather than
    a size ladder (a `staff` ceiling does not permit `owner`);
  * an AUDIENCE resolver returns user ids and nothing else, resolves to NOBODY
    on failure, and takes CONSTANT params -- the constraint worth knowing before
    you design around it;
  * templates re-ensure per seedVersion, rule groups are offered ONCE per group
    key, so a rule appended to an existing group reaches fresh installs only;
  * `ctx.events.emit` binds the owner and is fire-and-forget; `ctx.inbox.push`
    is the direct write, for when there is nothing for an operator to decide.

The template builds all of it: one trigger, one audience over the clan roster it
already had, one seeded body and one seeded rule group, and an emitter in
`boot.js` that fires on the TRANSITION rather than on the poll. Seven new tests,
including the audience that resolves to nobody when its query throws.

Three claims were wrong and are corrected here rather than shipped:

  * core validates `subjectKey` against the declared variables and refuses the
    module; the draft taught a cooldown keyed on `undefined`, which the check
    exists to prevent and a reader will never see.
  * `emit` throws OUTSIDE production and only drops-and-logs inside it. Teaching
    the second half alone leaves a developer meeting a throw the book says
    cannot happen.
  * the seeded body itself was malformed -- heading `level: 2` where the block
    registry takes 'h2', and no block ids at all.

The third is the one worth keeping: `registerEngagementSeeds` checks that
`blocks` is a non-empty array and stops, so that body would have registered,
seeded, and failed the first time an operator opened it. Found by running the
template's `register()` through core's real registry at the pinned ref -- which
CI does not do, and cannot: the template job checks the version and runs the
template against fakes. A fake accepts what core refuses. The gap is now named
in the chapter, beside the code, and in the pin's own comment, and the rule that
bit has a test that fails on it.

Also: `checkLinks` skipped `.core/`. Bumping this pin means cloning core into
that directory first, and the walk then reported nine broken links in someone
else's README. CI never saw it -- the clone happens in the `template` job and
the check runs in `prose` -- so it was a failure only a person could meet.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 12:57:55 -05:00
20 changed files with 2454 additions and 36 deletions

View File

@@ -57,7 +57,10 @@ typing commands can only be polled, and polling turns "someone left at 14:02" in
the contract cannot do yet.
2. **`template/`** — a module that builds and loads, doing almost nothing. Copy it,
rename it, and you have a running module before you have read a chapter.
3. **The book** — [`book/`](book/), four chapters, in the order the work happens.
3. **The book** — [`book/`](book/), five chapters, in the order the work happens.
The first four are the job. The fifth is optional and comes after you have a
working module: what to declare if you want a scheduled event on the website to
be able to change your live world, and get it back afterwards.
## The one rule this kit follows
@@ -69,6 +72,7 @@ kit and one of them disagree, they win and the kit has a bug:
| [`MODULE_API.md`][api] | Everything a module may do: `module.json`, `ctx`, the `register*` calls, the client registry, the UI kit, schema-fragment rules, the loader's obligations. |
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The shard↔sidecar wire protocol, as one real sidecar implements it. |
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, what core owns, and every rule chapter 5 explains the reasoning behind. |
The kit *teaches*: the order to do things in, the reasoning, worked examples, and
the mistakes that cost this project time. Where it must show a member list it
@@ -104,6 +108,7 @@ same licence, and so does anything derived from it.
[module-uo]: https://gitea.whitlocktech.com/RunicGateway/Module-uo
[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
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md

View File

@@ -147,8 +147,9 @@ statement of your dependencies, and it makes a test double for it — see
## What you register
Eight calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
is not their signatures but the model behind them.
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.
**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
@@ -279,6 +280,119 @@ 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
somebody's blog post edit would be a worse bug than a stale mirror.
### Telling core something happened
Three registrations and one call, and together they are the seam where a module
is most tempted to reach past the boundary. The rule that keeps them safe is one
sentence: **you declare what CAN happen; core decides who is told.**
A **trigger** is not a notification stream, and the two are easy to confuse
because both are catalogs of things that happen in your game. A stream is a
subscribe toggle, and you publish to it yourself. A trigger is a **payload
contract**: it names the variables an event carries and how wide an audience it
may ever be given, an operator writes rules against it, and *core* does the
sending. Their ids share one namespace, so declaring both for the same id is
legal — that is one event with a toggle and a contract — while taking an id
another module owns is not.
Two fields on a trigger are worth more than their size.
**`ceiling` is required and has no default, and the values are ordered by
containment rather than by size.** It is the widest audience a rule on this
trigger may ever be given. There is no safe value to guess: `owner` silently
breaks a broadcast, `authenticated` silently widens something meant for staff.
And the ladder reading of the seven values is the trap — a `staff` ceiling does
**not** permit `owner`, because "one person" for a cheat-detection event is *the
player it was detected on*. Fewer people is not less exposure.
**`subjectKey` must name one of your declared variables**, because it is what the
cooldown is keyed on — "once per house", not "once per user". Core checks it at
registration and refuses the module, so this is one you meet at your first boot
rather than in production. The check is there because the failure it prevents is
the silent kind: a subjectKey naming nothing keys every subject on `undefined`,
which looks exactly like the feature working right up until two houses share it.
Every variable needs an `example`, and it is not decoration: it is what lets an
operator preview and test-send a body without waiting for a real game event,
which is the reason template systems ship untested. The type set is closed and
has no `object` or `array` — a message that has to walk a structure has outgrown
interpolation.
An **audience** is a named set of *people* you can resolve over your own data,
for an operator to point a rule at. "This clan's members" is one. "Everyone who
opened the last mail" is not, and nothing here builds it.
**Your resolver returns user ids and nothing else.** It is not handed a template,
a channel or an address, and it cannot enumerate them; core maps ids to addresses
on its own side, after preferences, suppression and the verification gate. That
narrowness is deliberate — a module still cannot send mail, and this is the
obvious place a back door would go. Two consequences follow from it:
- **A resolver that fails resolves to NOBODY**, never to everybody and never to
its last good answer. Core enforces that, and your resolver should choose it
too, so the log can say which clan.
- **Its params are CONSTANT.** An operator fills them in when they save the rule.
There is no way to say "the clan this event was about" — if a rule needs that,
the *event* carries its own recipients instead. This is the constraint most
worth knowing before you design around it rather than after.
The third registration ships the **content**: the bodies your messages use and
the rules that decide when one is sent. Both arrive **switched off**, and
`enabled` is not a parameter. An operator turns a module's mail on; installing a
module never does.
The two halves have different lifetimes, and the asymmetry is the contract.
**Templates re-ensure on every boot** under a seed version, so a better default
reaches deployments that never edited it while one an operator *has* edited is
left alone. **Rule groups are offered once, per named group key**, because
re-offering would resurrect a rule somebody deleted and reset one they enabled.
The consequence is easy to trip over: a rule appended to an existing group
reaches **fresh installs only**. That is the guarantee rather than a limitation
to route around, and a rule that must reach existing deployments takes a new
group key. You name the groups, so the choice is yours to make knowingly.
Core's generic bodies are a first-class answer rather than a fallback. Point a
channel at `notify.event` or `inapp.event` and author nothing; ship a body of your
own when the message has something to say that a structural projection of the
payload cannot.
**One thing in a seeded body is not checked when you register it.** The call
asserts that `blocks` is a non-empty array and stops there; the body itself is
validated by the block registry, which runs in the editor and in the renderer. So
a malformed block registers cleanly, seeds cleanly, and first shows itself when an
operator opens the body or a rule fires. Build one, open it in Admin → Engagement →
Templates once, and you have checked the half that boot cannot.
Finally the call. `ctx.events.emit(triggerId, envelope)` fires one of your own
triggers — core binds the owner from the calling module and never reads it from
the arguments, so there is no shape of this call that fires somebody else's
event. It returns nothing and, in production, never throws: there is nothing a
module could correctly do about a delivery failure from inside a game-event
handler, so there is nothing to await. **Outside production it does throw**, at
your call site — a payload that does not match the contract you declared is a bug
rather than a condition, and the throw is how you meet it in your own tests
instead of in an operator's log six weeks later.
Beside it is the one call that skips the rules entirely.
`ctx.inbox.push(userId, item)` writes a single item into a single person's on-site
inbox. Reach for it when there is nothing for an operator to decide — a job that
person started has finished — and for anything else use a trigger, so the message
can be turned off, re-targeted, or sent by mail as well without a code change. The
posture is `emit`'s: the owner is bound from the calling module, it returns
nothing, and it will not tell you that the user has that channel switched off,
because a module that could see that could enumerate people's preferences one
write at a time.
**Emit on the transition, not on the poll.** The template's `refresh()` runs every
thirty seconds and emits only when the world's online state actually changed.
Core's cooldown and hourly cap would both hold if it did not — but leaning on
them means emitting "the world is still up" and calling it news, and the operator
who tightens the cooldown to stop it has hidden your bug rather than fixed it.
Two smaller traps sit inside the same function and are worth reading in
`template/server/boot.js`: the previous state has to be read *before* the write,
or every poll looks like no change at all, and the very first boot has no previous
state, which is not a change either.
### Becoming the source of Teams
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and

View File

@@ -89,6 +89,93 @@ A module cannot do any of this from inside the website process. There is nowhere
put what arrives while the website is not running, because the website not running
is exactly the case.
## 2a. The other direction, if you ever want events
Everything above is about data leaving the game. Skip this section until you want
[chapter 5](05-events.md) — but read it *before* you build the sidecar rather than
after, because retrofitting it is more work than allowing for it.
An event on the website is core telling your module *"do this to the world now"*,
and your module telling your sidecar, and your sidecar telling the game. That is a
**command** — a request with a reply, going the way nothing above goes. It needs
three things the read path does not.
**Request/reply correlation.** A command is not a broadcast: the caller waits for
an answer and has to know which answer is theirs. `uo-link` does this in
`sidecar/src/rpc.rs` — an id on the way out, a map of pending calls, the reply
matched back and the waiter woken. You need it for reads that ask the game a live
question too, so it is often already there; commands are what make it load-bearing.
**An idempotency key, executed at most once, stored where the game is.** Core hands
your module a key that is a function of the step's identity and never of the
attempt, so every retry carries the same one. The far end must execute a given key
once and answer a repeat with **the reply the first attempt produced** — not by
running the command again.
That store belongs as close to the game as the state it protects. A store in the
sidecar is right for a command whose effect is the sidecar's own; a command that
changes the *world* needs the store where the world is, because the case it exists
for is the game restarting mid-run. And a repeat arriving while the original is
still in flight is its own answer — "busy", transient by construction, because the
work is happening.
Without this, a command that arrived, ran, and whose acknowledgement was lost is
indistinguishable from one that never arrived. The only safe policy is then never
to retry, which means a game restarting mid-event writes the step off.
**A deadline the game enforces on its own.** A borrowed value — a doubled gather
rate, a raised spawn cap — carries an expiry down the wire, and the game side must
restore the baseline when it passes **without being asked again**. The website's
copy of that deadline is for the console. The game's copy is the fail-safe: if the
website is never heard from again, the value still comes back.
Two details that are easy to get wrong and expensive to change later. Send the
deadline as a **duration**, not an absolute time — two machines' clocks are two
clocks. And if the borrowed value lives in the game's own save file, the *hold*
must be persisted and the timer re-armed at load; a restart preserves the change
and destroys only the thing that would have undone it.
## 2b. The game host already has the files your site wants
There is a third kind of traffic, and it is worth knowing about before you decide
your sidecar only ever forwards live state. Most games keep **content on the host**
that a website wants to show: sprites, icons, portraits, localisation tables, map
or spawn definitions. It is static, it is large, and it changes only when an
operator patches the game.
The tempting answer is to make the operator's problem: export it on a desktop with
some third-party tool, upload the result, repeat after every patch. It works once
and rots immediately, because nothing reminds anyone to redo it.
The better answer costs less than it sounds like: **the game host already has those
files, and you already have a channel to the game host.** Route them over it.
Four design notes, all learned the expensive way in `uo-link`'s protocol 8 (the
"asset bridge", [`v8.md`][v8]):
- **This is request/reply, never events.** A sidecar that persists and broadcasts
every event would write megabytes of sprite into its own store and fan it out to
every connected client. Content must ride the same correlated round-trip a query
uses — see [chapter 5](05-events.md) for the shape.
- **Serve one at a time, and say so in the protocol.** Decoding assets costs the
game host real memory. One in-flight request with an explicit "busy" answer is
simpler and safer than a queue, and a caller that treats busy as flow control
rather than failure gets a working import out of it.
- **Two stages: what exists, then what changed.** A cheap call that returns a list
with a hash per item and no content, then a second that fetches only the hashes
that moved. The common case — a restart that changed nothing — must cost one
small round trip, not a re-download of everything.
- **Version your *derivation*, separately from the protocol.** If you improve how
you read a file, the bytes you produce change while the source file's hash does
not. `uo-link` carries an `EXTRACTOR_VERSION` for exactly that, and a consumer
treats a change in it like a changed hash.
And one operational note, because it is the part that surprises people: **do not
import on boot.** A patch is an event the operator knows about and your website does
not. Re-reading hundreds of megabytes on every restart to discover that nothing
changed pays for the rare case forever; a button an operator presses after they
patch costs nothing and is honest about who knows what.
## 3. The wire is a versioned contract, not a build dependency
Your sidecar and your module ship separately, on different schedules, to hosts you
@@ -200,4 +287,5 @@ it wrong takes the game down rather than the website.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
[v8]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md

View File

@@ -103,6 +103,53 @@ escape into a game code path. `BridgeLink` wraps the inbound handler and logs
anything it throws, because the alternative is an exception unwinding somewhere in
the engine's main loop.
## A command that changes the world runs at most once
Skip this until you want [chapter 5](05-events.md). Everything above assumes an
inbound line either asks a question or is a one-off an operator typed. An **event**
is neither: it is unattended, it is retried, and what it does is permanent.
Three obligations, and they all live on this side of the wire because this is the
side that has the world.
**Keep a key store, and persist it.** Every command an event sends carries an
idempotency key — a function of the step's identity, never of the attempt, so a
retry carries the one the first attempt did. Before executing, look the key up:
- **not seen** — execute, then record the key *with the reply you are about to
send*;
- **seen and finished** — send that stored reply back, unchanged. Do not re-run;
- **seen and still running** — answer "busy". It is transient by construction, and
the caller will retry; running it concurrently with itself is the failure.
The stored reply matters as much as the guard. A repeat that re-ran and returned a
*new* serial would be two things in the world and one in the website's ledger,
which is the exact failure the key exists to prevent, arrived at by a longer route.
**Persist it in the world save, not in memory**, if what the command creates
survives a restart. The case this whole mechanism exists for is a game restarting
mid-event, and a key store that dies with the process is a store that is empty in
precisely that case.
**Own what an event made, and expire what it borrowed.**
An event-created thing has to be findable again later, because the website will ask
you to remove it after the event and may ask more than once. That means a registry
— a persisted map from the website's reference to the object — and it means
`remove` is idempotent: **removing something that is not there is a success.** The
website records a resource *before* it is confirmed, so it will ask you about
things that may never have existed, and neither end can tell the difference.
A borrowed value is the mirror. It arrives with a duration, and you arm a timer that
puts the baseline back when it expires. If the borrowed value lives in the save
file, persist the hold and **re-arm the timer at load** — a restart preserves the
change and destroys only the thing that would have undone it. Restore with a
compare-and-set against what you applied: if a staff member has moved it by hand
since, report that rather than overwriting them.
The through-line: **the game enforces the expiry, not the website.** If the website
is never heard from again, every borrowed value still comes back on its own.
## Reconnect, and what to send on connect
Your sidecar restarts independently of your game. It comes back with an empty
@@ -173,12 +220,25 @@ Two practical notes from that file, both general:
If all eight hold, the worst a broken sidecar can do to your game is nothing at
all — which is the entire point of the arrangement.
Three more, and only if you took commands (chapter 5):
9. A command's idempotency key is looked up before it is executed, and a repeat is
answered with the stored reply rather than re-run.
10. What an event made is in a persisted registry, and removing something absent is
a success.
11. A borrowed value's expiry is armed by this side, re-armed at load, and restored
with a compare-and-set.
---
That is the book. The three parts are a module core loads, a sidecar that owns the
game connection and the durable copy of what it said, and a plugin that feeds the
sidecar without ever waiting on it.
[Chapter 5](05-events.md) is the optional fifth part: what to declare if you want
the website to be able to change your world on a schedule, and the four mistakes
that make that unsafe.
If you got this far and built something, the places you got stuck are the most
valuable thing this repo can receive — [tell us][issues], and please say where you
left the kit and what you did next.

364
book/05-events.md Normal file
View File

@@ -0,0 +1,364 @@
# 5. Making your module event-capable
Chapters 1 to 4 got a game onto the platform: a module that reads, a sidecar that
stores, a plugin that tells it what happened. Everything in them moves one way —
out of the game and onto a page.
This chapter is about the other direction. The event system is core's engine for
**scheduled, bounded, audited changes to a live game world**: an operator writes an
event on the website — a phase that announces, a phase that spawns something, a
phase that waits for a condition, a phase that cleans up — publishes it, schedules
it, and it runs unattended at two in the morning. Your module is what lets any of
that touch your game.
It is also the first thing in this book that can do damage. A page that renders
wrong is embarrassing. An action that half-ran and was recorded as done is a
change to a live world with nothing coming back for it.
Nothing here is normative. [`EVENTS.md`][events] is the design of record and
[`MODULE_API.md`][api] is the contract; where this chapter and either of those
disagree, they are right and this chapter has a bug. What is here is the ordering,
the reasoning, and the four mistakes that are invisible until an outage.
---
## Everything in this chapter is optional
Stated first because it changes how you should read the rest.
A deployment with **no module at all** still has a working event engine. Core owns
verbs of its own — announce something, wait, cue a human to do the in-game part,
publish results — and an event composed only of those runs on bare core with zero
modules installed. That is not a degraded mode; it is a real product, and for many
games it is the whole of what you want.
So each of the four declarations below *adds* something an author can reach for.
Registering none of them costs your deployment a capability, never a boot — the
same posture as a module with no `onBoot`, which still reaches `started`.
Which means you can stop reading at any section boundary and ship what you have.
## The four declarations
```js
api.registerEventBudgets([...]) // dimensions core can COUNT and BOUND
api.registerEventOptionSources([...]) // what a dropdown on the form is FILLED from
api.registerEventLeases([...]) // values a run may BORROW, with a deadline
api.registerEventActions([...]) // verbs a run may PERFORM
```
Four separate id spaces, each namespaced under your module id. `examplegame.beacons`
as a budget and `examplegame.beacon.light` as an action are not a collision, and
reading them as one would forbid the most natural set of names you will ever
write. An action names a VERB, a budget a RESOURCE, a lease a VALUE, an option
source a CATALOG.
All four are in the template at
[`template/server/config/eventActions.js`](../template/server/config/eventActions.js),
one of each, with the four traps marked where they bite. Read that file beside
this chapter.
## Build the lease first
If you have time for one thing, build a lease, not an action. This is the kit
disagreeing with the obvious priority on purpose.
The obvious thing to build is spawning: an event that puts creatures at a landmark
is what a game event *looks* like. But spawning is a shape one genre happens to
have, and it is the harder half — something now exists that did not, and your
module owes core a way to take it away again on every terminal path, including
the ones where nobody is watching.
A lease is the other shape: **a value that already existed, changed for a while,
and put back.** "Double the gather rate for the weekend." "Turn the night length
down until Sunday." "Raise this spawner's population for the invasion." That is
the canonical community event in most games, and it is cheaper to make safe,
because the value you are replacing already exists and reading it first gives you
your baseline for nothing.
**The verb is core's, not yours.** You declare what can be held and how long; an
author puts `core.lease` in a step naming your lease, a value and a number of
minutes, and core reads the baseline, reserves the target, applies the value with
a deadline, and restores it at teardown through your own `restore()`. A lease verb
of your own would be that duration bound and that "two events cannot hold one
target" check re-implemented once per module — advisory everywhere, and wrong in
the first one that forgot it.
```js
api.registerEventLeases([{
id: 'examplegame.rate.gather',
label: 'Gather rate',
type: 'float', min: 0.5, max: 5,
maxDurationMs: 48 * 60 * 60 * 1000,
async read() { /* the live baseline */ },
async apply(value, until) { /* hold it, and send `until` down the wire */ },
async restore(baseline, { expected }) { /* put it back, or report drift */ },
async inForce() { /* optional — a FOURTH question, see below */ },
}])
```
Three things about that shape are worth more than their size.
**`until` goes down the wire and the far end honours it without being asked
again.** Core's copy of the deadline is for the console; the game's copy is the
fail-safe. A module that passes `until` and then relies on core coming back to
restore has built a lease that outlives an outage — which is the one thing a lease
exists to prevent. If the website is never heard from again, the value must still
come back.
**`restore()` reports drift rather than overwriting it.** `expected` is what core
believes is applied. If the live value differs, somebody moved it by hand during
your event, and answering `{ ok: true, drifted: true, value }` lands the row as
`drifted` with the current value beside it. Silently restoring over a human's edit
is the bug this exists to prevent.
**`inForce()` is a fourth question, not a fourth spelling of `read()`.** It asks
*"does the game side still have any record of this hold?"*, and none of the other
three answers it. A value that DIFFERS from what the run applied is drift, which
`restore()` reports; a reconcile that inferred absence from a changed value would
take the row out and tell an operator the lease vanished rather than that somebody
moved it. Optional — and `{ ok: true, held: false }` is the only thing that takes
a lease's ledger row out. A throw, a refusal, or no `inForce()` at all leaves the
row alone.
**Only advertise a lease you have verified takes effect.** A value your game reads
once at start-up and caches will apply cleanly, read back cleanly, and do nothing
at all. Core cannot catch that and neither can review — it is a capability that
lies. Apply it, observe it in the running game, restore it. Per key, as a test.
The UO module surveyed 156 config reads in its game and found roughly eight that
were live; the rest were cached at boot and would all have lied.
## Actions, and what "owning" something means
An action is a verb an author puts in a step. What it makes, the run OWNS until
teardown.
```js
api.registerEventActions([{
id: 'examplegame.beacon.light',
label: 'Light beacons',
risk: 'change', // notify | inspect | change | irreversible
reversible: 'ledger', // none | self | ledger | override
version: 1,
budgetMs: 15000,
cost: (p) => ({ 'examplegame.beacons': p.count }),
params: [ /* every one carries an `example` */ ],
async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {},
async revert({ runId, resources, idempotencyKey }) {}, // required iff 'ledger'
async reconcile({ runId, resources }) {}, // optional
}])
```
**`reversible: 'ledger'` is a promise.** It says core may record what you made and
come back later to have it undone, and it makes `revert` required. Core's cleanup
is **derived, not authored**: there is no `on_teardown` field and no cleanup phase
in a spec, because an operator cannot be relied on to write the undo and an
aborted run never reaches the phase they wrote it in. Cleanup is one sweep over
the ledger and it runs on every terminal path — completion, cancellation and abort
alike. Your only job is to answer `revert` correctly, however many times you are
asked.
**`verify: true` must change nothing and must answer honestly.** It is the dry
run, and it rides the same dispatcher a real run uses — because a dry run down a
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.
**`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
unreconstructable afterwards by anybody who did not write the action.
**A `source` on a param makes it a dropdown**, filled by an option source you (or
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.
---
# The four things that are invisible until an outage
Everything above is ordinary. These four are the ones that look like they are
working, in every test you write and every demo you give, right up until the day
something is down.
## 1. The failure default is a retry, and `budgetMs` is what makes the other half reachable
**No shape a failure can take reads as success.** A rejected promise, a throw, a
budget timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`.
`retry` is opted OUT of: a module that means "this will never work" must say
`retry: false`.
That direction is deliberate, and it is `registerTeamProvider`'s default
*inverted*. A Team provider that refuses leaves core showing what it had, because
staleness is cheap. An action that half-ran and was recorded as done is a world
change nothing will ever come back for.
Now the part that is easy to miss. Core's dispatcher enforces `budgetMs`, and when
the budget expires it classifies the failure as **retry, unconditionally, without
asking you** — it cannot ask, your action is still awaiting a socket.
**So if your transport's timeout is longer than `budgetMs`, your own `retry: false`
is unreachable code.** Core's default `budgetMs` is 10 seconds. If your sidecar
client waits 12, core's deadline fires first on every slow game and the step is
retried no matter what your envelope says. The first module this project shipped
had exactly that pairing, and its one deliberately un-retryable verb was retried
anyway for a whole phase.
The rule generalises past that one pairing: **an action is the near end of a call
with a far end, and the near end has to outlive it.** Derive one constant from the
other rather than typing both, and assert the inequality in a test — the template
does both, because a number typed twice drifts the first time somebody tunes the
client and does not think to look at the other file.
**The reason a refusal gives goes in `error`.** Core reads exactly `ok`, `retry`
and `error` off a failure envelope; a message under any other name is dropped in
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.
## 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
from the attempt number — so **every retry carries the same one**. The far end,
which is the only end that can tell a retry from a repeat, executes a key at most
once and answers a repeat with the ORIGINAL reply rather than running it again.
Pass it through unchanged. A module that invents its own key here, or drops it,
has an action that cannot be retried safely, and the cost of that is not a failed
step: it is a second set of everything on a socket hiccup. It looks correct in
every test you will write, because in every test the first attempt succeeds.
It is also what a lost acknowledgement is recovered from. Without a key, a command
that arrived, ran, and whose reply was lost is indistinguishable from one that
never arrived — so the only safe policy is never to retry, and a game restarting
mid-run writes the step off. With one, the retry collects the answer the first
attempt never delivered.
**And it belongs on a command, never on a question.** This is the correction
writing the template produced, and it is quiet and total: an at-most-once store
answers a key it has already seen with the first reply, forever. So a *read* that
carries a key returns the first read's value on every subsequent call — the lease
applied correctly, the game changed correctly, and the module could no longer see
any of it. `read()` reported the pre-run baseline and `inForce()` said nothing was
held. The template splits its client into `ask()` and `send()` for exactly this
reason.
The rule for which commands need a key is narrower than "all of them", too. A key
is for a write whose repetition would be a second EFFECT — creating, granting,
announcing. A write that SETS a value to X is idempotent by its own nature: doing
it twice is doing it once, and a key would only pin its reply.
Build the store on the **far end**, and persist it. A store in your module answers
nothing, because the case that matters is the one where the command arrived and
ran. See [chapter 4](04-game-plugin.md) for the game-side half.
## 3. Core records a resource BEFORE it is confirmed
This is one line in [`EVENTS.md`][events] §D and it decides the whole shape of your
`revert`.
Core writes a placeholder into its ledger, keyed by the step's idempotency key,
**before** dispatching — so a dispatch whose answer never came back is still
something cleanup can act on. Your `resources` are the refs core did not know until
the answer arrived, filled in afterwards.
Two consequences, and both are about what `revert` must tolerate:
**Reverting something that does not exist is a SUCCESS.** Cleanup will ask you
about rows for things that may never have existed. You must never have to tell
"I removed it" from "it was not there" — and you could not, because your game
cannot either. Answer `{ ok: true }`. This is also what a game with a monthly wipe
needs, where every ledgered resource is invalidated at once and "gone, and that is
fine" is the only useful answer.
**You will be called with NO resources and only a key.** That is the lost-answer
case stated exactly: core knows a dispatch went out under this key and never
learned what it made. A module that can undo by key answers honestly. One that
cannot answers `{ ok: false }`, and the row stays visible to an operator — which is
the correct outcome, not a silent one. Answering `{ ok: true }` to a question you
cannot answer is how something burns in a live world forever with core's ledger
reporting it cleaned up.
`revert` must also be idempotent, because core may ask more than once.
**`reconcile` is optional where `revert` is required, and the asymmetry is the
design.** A module that cannot say what the game still has is not broken — core
keeps believing its own ledger, which is the behaviour before any of this existed.
One that created something and cannot undo it has made a promise core has no way
to keep.
And when you do answer: **anything that is not an explicit
`{ ok: true, inForce: [...] }` leaves the ledger alone.** "I do not know" is never
read as "it is gone". A resource you report missing becomes `orphaned` rather than
`reverted`, because nobody asked for it to go.
**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
its own boot, and otherwise waits to be told. `ctx.events.reconcile()` is being
told, and the thing that triggers it is your own watch on a boot id changing — which
is also how you tell a game restart from a sidecar reconnect. They are not the
same event; the second loses nothing.
## 4. Under-declaring `cost` turns every cap into a lie
`cost(params)` says what one invocation consumes. An operator sets caps per
dimension, and core refuses a step that would exceed one.
**Core prices `cost` before dispatch and never reconciles it against the resources
that come back.** It cannot — it does not know what a beacon is. So an action that
returns `{ 'examplegame.beacons': 1 }` while lighting twelve turns an operator's cap
of 30 into a cap of 360, the meter on the run console agrees with the lie, and
nothing anywhere goes red. The first symptom is a world with an order of magnitude
more in it than anyone authorised.
Count what you will actually make, from the params you were given, every time. **If
you cannot know until the answer comes back, declare the maximum**: a spend that is
too high refuses an event that would have fit, which an author can see and argue
with; one that is too low cannot be seen at all.
Two smaller rules ride with it:
- **You cannot spend a dimension no module declared.** A `cost()` naming an
unregistered one is refused at save, at the dry run and at dispatch, with its own
refusal code — because the fix is a module's declaration and not a deployment's
cap.
- **Declaring a dimension is not the same as bounding it.** A declared dimension
with no operator cap is counted and unbounded, which is useful on its own: the
run console then shows an author what their event actually spent.
---
## What core owns that you might think is yours
Four things a second module's author reaches for and should not.
| You might build | Core already owns it | Because |
| --- | --- | --- |
| A `myGame.lease` verb | `core.lease` | the duration bound and the two-events-one-target check belong in one place, or they are advisory everywhere |
| A cleanup phase, or `on_teardown` | the ledger sweep | an aborted run never reaches the phase somebody wrote the undo in |
| Deciding who is told about your event | rules and audiences | you declare what CAN happen; core decides who is told ([chapter 2](02-website-module.md)) |
| A second write path for participants | the `participants` envelope member | a second door into a run core is mid-tick on is a second thing that can race the step claim |
## What to build, in order
1. **Nothing.** Confirm an event composed of core's own verbs runs on your
deployment. If it does, the engine is working and everything below is additive.
2. **One budget dimension**, declared and uncapped. Costs nothing and makes the
next step legible.
3. **One lease**, verified live — apply, observe in the running game, restore.
This is the primitive that travels, and for many games it is the whole feature.
4. **One option source**, so the authoring form stops asking operators to type
identifiers from memory.
5. **One action that ledgers**, with `revert` and the four traps above. This is
where the work is, and where the damage is.
6. **`reconcile`**, and the boot-id watch that calls `ctx.events.reconcile()`.
Last, because it is the only one whose absence is merely a lower standard
rather than a broken promise.
Then read your own `revert` again, and ask what it answers when the game is down.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md

View File

@@ -1,6 +1,7 @@
# The book
Four chapters, in the order the work happens.
Five chapters, in the order the work happens. The first four are the job; the
fifth is optional and comes after you have one.
Read [the dry run][dryrun] before any of them — a complete module designed on
paper for a second game, and the shortest honest picture of the whole job.
@@ -11,12 +12,20 @@ paper for a second game, and the shortest honest picture of the whole job.
| 2 | [The website module](02-website-module.md) | The bulk of the work: `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do. |
| 3 | [The sidecar](03-sidecar.md) | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is. |
| 4 | [The game-side plugin](04-game-plugin.md) | The least code and the highest stakes: never block the game thread. |
| 5 | [Making your module event-capable](05-events.md) | Optional, and the first thing here that can do damage: letting a scheduled event on the website change your live world, and get it back. |
Chapters 1 and 2 quote `template/`, which CI builds against a pinned core, so their
code is a tree that is proved rather than prose that looks like one. Chapters 3 and
4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by line, on
purpose: those repositories move for their own reasons and a line number in a book
is wrong the moment they do.
Chapters 1, 2 and 5 quote `template/`, which CI builds against a pinned core, so
their code is a tree that is proved rather than prose that looks like one. Chapters
3 and 4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by
line, on purpose: those repositories move for their own reasons and a line number in
a book is wrong the moment they do.
**Chapter 5 is the one you can stop before.** Chapters 1 to 4 get a game onto the
platform and everything in them moves one way — out of the game and onto a page.
Chapter 5 is the other direction, and a deployment that never reads it still has a
working event engine over core's own verbs. Chapters 3 and 4 each carry one section
that only matters if you are going there (§2a and *"A command that changes the
world runs at most once"*); both say so at the top.
## What is normative, and what is here
@@ -28,12 +37,14 @@ document is right and the chapter has a bug — [say so][issues]:
| [`MODULE_API.md`][api] | Everything a module may do. |
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The game↔sidecar wire protocol, as one real sidecar implements it. |
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, and what core owns. |
The chapters teach: the order to do things in, the reasoning, and the mistakes that
cost this project time.
[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
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md

View File

@@ -1,34 +1,60 @@
{
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"branch": "main",
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
"why": [
"The core this kit is written against, pinned to a commit rather than a branch.",
"This one is the Teams cutover, the commit MODULE_API_VERSION 1.6.0 reached",
"`main` on, and 1.6.0 is what template/module.json declares. It moved here from",
"the 1.5.0 bump because Teams expanded the contract the book teaches: the",
"template now registers a Team provider and declares slots for core to fill,",
"and both are members that exist only at this ref and later.",
"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",
"declares. It moved here from 66bb3b9a (1.9.0, the engagement cutover) because",
"the event contract expanded the book by a whole chapter: a module now declares",
"what its game can DO on request -- event actions, budget dimensions, leases and",
"option sources -- where every earlier chapter taught only a read path and a",
"thing to announce.",
"",
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
"version template/module.json declares still equals this core's",
"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). Note what that means in the other",
"direction, because it is easy to misread as a safety net: the check clones",
"THIS ref, so a core that has moved past it does not turn the repo red on its",
"own. Nothing goes red until someone moves the pin. Between cutovers the kit is",
"not wrong, it is DATED - and this file is where the date is written down.",
"",
"That mechanism earned its keep this time. Writing the chapters against 1.6.0",
"found that core's inverted-slot fills named three of module-uo's slots",
"literally, so the direction worked for that one module and silently did",
"nothing for any other game - an empty page with nothing logged. That is the",
"exact class of thing a book written for an audience outside this org is meant",
"to catch, and it was fixed in core before this pin moved.",
"This pin move is a REPAIR as well as a date. Chapter 5 landed (#10) declaring",
"^1.10.0 while this file still named a 1.9.0 core, so `main` has been red on",
"checkCoreApi since it merged -- deliberately, and stated in that PR, but the",
"red belongs to the window and not to the repo. This is the commit that was",
"always going to close it, and it could not be written until the events sha",
"existed on `main`. Same shape Teams phase 11 used.",
"",
"The mechanism earned its keep again here, and twice. Writing chapter 5 against",
"the event contract found that an idempotency key on a QUESTION makes every",
"later read permanently stale -- an at-most-once store answers a repeated key",
"with the ORIGINAL reply, so the template's second read of a value returned the",
"first read's answer for ever, and the module could not see a change it had just",
"made. It also found that a refusal's reason goes in `error`: core's classifier",
"reads no other name, so a refusal reported under `detail` reached an author as",
"a bare \"refused\". Neither was found by writing prose. Both were found by",
"running the template's real declarations through core's real registry and its",
"real envelopes through core's real dispatcher.",
"",
"That is also why this file's own instruction is not enough on its own. The",
"template job builds and tests the template against fakes and checks this",
"number; it does not load the module into core. A declaration a fake accepts",
"and core refuses would ship green, so a pin move is a run against a real core,",
"not just an edit here. It was run at THIS ref: core's real registries accepted",
"the template's budget, option source, lease and event action, and apply()",
"accepted the set.",
"",
"The branch said `edge` until 2026-08-12, when the module system cut over and",
"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.",
"that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an",
"`edge` of their own and this pin skipped both; the Event System cut a third,",
"and this pin skipped that too until it reached `main`. 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",
"sha, reviewable in a diff."

View File

@@ -24,8 +24,14 @@ const { stripFences } = require('./lib/markdown')
const ROOT = path.resolve(__dirname, '..')
const QUIET = process.argv.includes('--quiet')
// Directories that hold no prose we own.
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
// Directories that hold no prose we own. `.core` and `core` are core's own
// checkout: .gitignore reserves both because moving `ci/core-ref.json` means
// cloning core in here first, and without this that clone hands the reader nine
// broken links in somebody else's README the moment they follow the pin-bump
// instructions. CI never saw it — the clone happens in the `template` job and
// this check runs in `prose` — which is exactly the kind of failure that only
// ever meets a person.
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', '.core', 'core'])
/** Every markdown file in the repo, repo-relative, sorted. */
function markdownFiles(dir = ROOT, out = []) {

View File

@@ -15,6 +15,9 @@ Installed into a core, it adds:
for the deployment — the one registration where core calls YOU and waits;
- **three inverted extension slots**, declared by this module on the clan page for
core to fill;
- **four event declarations** — a budget dimension, an option source, a lease and
one action that ledgers what it makes — so an event authored on the website can
reach the game and be undone afterwards;
- **both lifecycle hooks**, so there is something to see at boot and at shutdown.
That is deliberately less than your module will do. What it is *complete* about is
@@ -28,7 +31,9 @@ module.json what core reads first — id, version, coreApi, moun
server/
index.js register(ctx, api) — the entire server-side handshake
core.js the lazy accessors over ctx; read this second
boot.js onBoot / onShutdown
boot.js onBoot / onShutdown, and the game-restart watch
sidecarClient.js the one file that talks to your sidecar — transport simulated
config/eventActions.js budgets, option sources, leases and actions — read chapter 5
db/schema.sql idempotent, replayed every boot
db/purge.sql destructive, run only by an explicit admin purge
model/worldStatus/ the .db.js / .model.js pair
@@ -38,6 +43,7 @@ server/
scripts/checkImports.js the module boundary, enforced
scripts/swaggerFragment.js generates swagger-fragment.json from your own routes
test/ the suites — start with entry.test.js
test/eventActions.test.js the four traps chapter 5 is about, each as a failing test
client/
vite.config.js the library build: anchored aliases, external: []
src/entry.jsx registers routes, nav and declared slots at evaluation time
@@ -112,6 +118,8 @@ backticking table names**.
| `.gitea/workflows/release.yml` | `GITEA_HOST` and `REPO`, under the `# CHANGE THESE` banner — the only two, and they are wrong until you do. (The `.github/` flavour needs nothing: GitHub supplies `GITHUB_REPOSITORY` and friends.) |
| `server/package.json` | package `name` and `description` |
| `server/core.js` | the message every accessor throws |
| `server/index.js` | the trigger, audience, template and rule-group ids — all four are namespaced with your module id, and core refuses them otherwise |
| `server/config/eventActions.js` | the budget, option-source, lease and action ids — four separate id spaces, each namespaced with your module id — and every command name the client sends |
| `server/boot.js` | the placeholder world name |
| `server/db/schema.sql` | every table name — the prefix must be your id |
| `server/db/purge.sql` | the same table names |
@@ -123,6 +131,8 @@ backticking table names**.
| `server/swagger/doc.js` | the tag, and the `Examplegame…` schema prefix |
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
| `server/test/_fakes.js` | `ctx.moduleId` |
| `server/test/entry.test.js` | the trigger id the world-status test asserts |
| `server/test/eventActions.test.js` | the action and lease ids it looks up, and the clan fixture |
| `server/test/worldStatus.test.js` | the fixture's world name |
| `server/test/clanProvider.test.js` | the fixture's world name |
| `server/package-lock.json` | **regenerated**`npm install --prefix server` |
@@ -145,6 +155,13 @@ placeholder and is not listed fails the build, and so does a listed file with
nothing left to rename. A checklist nobody verifies is a checklist that is wrong
by the second edit.
**`server/sidecarClient.js` is not on that list and is not an oversight.** It
carries no placeholder id — its vocabulary is the game's, not the module's — so
the checker has nothing to hold it to. It is still the file you have the most work
in: replace `deliver()` with one request to your sidecar, replace the fake game's
verbs with your game's, and set `TIMEOUT_MS` to what your transport actually
waits. Chapter 5 is mostly about that file.
Two things you do **not** rename: the mount prefixes `/world` and `/clans` need
not be your id (the server's prefix namespace is shared with core's — `/status`,
`/settings`, `/version`, `/contact` and `/teams` are already taken, which is why

View File

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

View File

@@ -31,6 +31,7 @@ const core = require('./core')
const worldStatusDb = require('./model/worldStatus/worldStatus.db')
const clanDb = require('./model/clans/clanProvider.db')
const sidecar = require('./sidecarClient')
const log = core.logger('boot')
@@ -39,6 +40,10 @@ const log = core.logger('boot')
// so that there is something for the shutdown hook to actually do.
let refreshTimer = null
// The last boot id the game reported. `null` means "never observed", which is not
// the same as "changed" — see `checkForRestart`.
let lastBootId = null
const REFRESH_MS = 30 * 1000
/**
@@ -52,12 +57,89 @@ async function refresh() {
try {
// A real module calls its sidecar's REST API here. Two hardcoded values
// stand in, so that the page renders and the seam is visible.
await worldStatusDb.setStatus({ online: true, players: 0, worldName: 'Example World' })
const next = { online: true, players: 0, worldName: 'Example World' }
// ── Emitting a declared event ──────────────────────────────────────────
//
// **Emit on the TRANSITION, not on the poll.** This function runs every
// thirty seconds; a rule on an event fired every thirty seconds is a rule
// that mails somebody every thirty seconds. Core has a cooldown and an
// hourly cap and they would both hold, but leaning on them means the module
// is emitting "the world is still up" and calling it news. Read the previous
// state, compare, and emit only when the answer changed.
//
// The read is BEFORE the write for the same reason, and getting that
// backwards is the easy version of this bug: after `setStatus` the previous
// value is gone and every poll looks like no change at all — an emitter that
// never fires and never errors.
const previous = await worldStatusDb.getStatus()
await worldStatusDb.setStatus(next)
// Same poll, different question: did the thing we lit beacons in restart?
checkForRestart()
// `previous === null` is the first boot on a fresh install, not a change.
// Treating it as one would announce the world coming online to everyone the
// first time an operator started the site.
if (previous && Boolean(previous.online) !== next.online) {
// Fire-and-forget: no await, no return value, nothing to handle. Core
// validates the payload against what `index.js` declared, and what a
// mismatch does depends on where you are running. **In production it is
// dropped and logged** against this module, because a notification must
// never be able to break the thing it is about. **Anywhere else it throws**,
// at this line, so the stack points at your own call instead of at a
// warning nobody reads. Neither is a condition to catch: a payload that
// does not match the contract you declared is a bug to fix.
core.emit('examplegame.world.status_changed', {
data: {
worldName: next.worldName,
status: next.online ? 'online' : 'offline',
players: next.players,
url: '/world',
},
})
}
} catch (err) {
log.warn('could not refresh world status', { error: err.message })
}
}
/**
* Notice that the game restarted, and tell core.
*
* **Core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
* from a dispatch and cannot tell a wedged sidecar from a game that rebooted and
* lost every beacon an event lit. Only this module knows, because only this
* module watches the feed the boot id arrives on — which is also how you tell a
* game restart from a sidecar reconnect, and they are not the same event: the
* second loses nothing.
*
* So core asks once, at its own boot — the one reconnect it can see — and
* otherwise waits to be told. `core.reconcileEvents()` is being told. It returns
* at once and core sweeps its resource ledger on its own time, putting the
* question back to this module as `reconcile({ runId, resources })` in
* `config/eventActions.js`.
*
* Called from the same poll as everything else here, because a boot id is just
* another thing the feed carries. In a real module this is a frame handler rather
* than a comparison against a remembered value.
*/
function checkForRestart() {
const bootId = sidecar.currentBootId()
if (lastBootId === null) {
// First observation is not a restart. Recording it as one would ask core to
// reconcile every ledgered resource on every website deploy, which is a sweep
// that costs a round trip per action for no news.
lastBootId = bootId
return
}
if (bootId === lastBootId) return
lastBootId = bootId
log.info('game restarted, asking core to reconcile', { bootId })
core.reconcileEvents()
}
/**
* Two clans, so that the Team provider has something to be authoritative about.
*
@@ -124,7 +206,8 @@ async function onBoot() {
async function onShutdown() {
if (refreshTimer) clearInterval(refreshTimer)
refreshTimer = null
lastBootId = null
log.info('shut down')
}
module.exports = { onBoot, onShutdown, refresh, seedClans, REFRESH_MS }
module.exports = { onBoot, onShutdown, refresh, seedClans, checkForRestart, REFRESH_MS }

View File

@@ -0,0 +1,447 @@
// ── What an event author can reach for ────────────────────────────────────
//
// MODULE_API.md 1.10.0 and `website/EVENTS.md` §F. Four declarations, all
// optional, and together they are how a scheduled event on the website reaches
// into your game and comes back out again.
//
// **All of it is optional, and that is the contract's own posture, not a hedge.**
// A deployment with none of this installed still has a working event engine: it
// can announce, wait, cue a human and publish results, over core's own verbs.
// What these four add is the ability for an event to reach the GAME. A module
// that registers none of them costs its deployment a capability, never a boot.
//
// ── The order to read this file in ────────────────────────────────────────
//
// A BUDGET names a resource dimension core can bound. An OPTION SOURCE answers a
// dropdown on the authoring form. A LEASE is a value a run may BORROW, with a
// deadline. An ACTION is a verb a run may perform, and what it makes it OWNS
// until teardown.
//
// Four separate id spaces, each namespaced with your module id. `examplegame.beacons`
// as a budget and `examplegame.beacon.light` as an action are not a collision;
// reading them as one would forbid the most natural set of names you will ever write.
//
// ── Own versus borrow, and which one to build first ───────────────────────
//
// This file declares one of each on purpose, and if you only have time for one,
// **build the lease.** `EVENTS.md` §H is blunt about it: the lease is the
// primitive that travels and object creation is the special case. "Double the
// gather rate for the weekend" is the canonical community event in almost every
// game — set a value, hold it, put it back — while spawning creatures at a
// landmark is a shape one genre happens to have. A lease is also the cheaper
// thing to make safe, because the value you are replacing already existed and
// reading it first gives you a baseline for free.
//
// ── The four things that are invisible until an outage ────────────────────
//
// Everything below is ordinary except four rules, and all four are the kind that
// look like they are working right up until the day something is down. They are
// marked TRAP 1..4 where they bite. In short:
//
// 1. **No shape a failure can take reads as success**, and `retry: true` is the
// default — so `budgetMs` must EXCEED your transport's own timeout or your
// own `retry: false` is unreachable code. The reason a refusal gives goes in
// `error`; core reads no other name.
// 2. **Pass `idempotencyKey` through, unchanged, on every attempt** — and put
// it on a COMMAND, never on a question. It is the only thing standing
// between a retry and a second world change, and the only thing that can
// make a read permanently stale.
// 3. **Core records a resource BEFORE it is confirmed**, so `revert` will be
// called about things that may never have existed — and about nothing at
// all, with only a key.
// 4. **`cost` is priced before dispatch and never reconciled against what came
// back**, so an action that under-declares turns every cap into a lie.
const core = require('../core')
const sidecar = require('../sidecarClient')
const clanDb = require('../model/clans/clanProvider.db')
const log = core.logger('events')
// ── TRAP 1 ────────────────────────────────────────────────────────────────
//
// Core's dispatcher enforces `budgetMs`. When it expires the dispatcher stops
// waiting and classifies the failure as **retry**, unconditionally, without
// asking the action — it cannot ask, the action is still awaiting a socket.
//
// So an action whose own client gives up AFTER core's deadline never gets to
// classify its own failure, and every `retry: false` it might return is
// unreachable code. Core's default `budgetMs` is 10s; this module's client waits
// 12s; on the default the deadline would fire first on every slow game and the
// step would be retried by core no matter what this file says.
//
// Hence: strictly greater than `sidecar.TIMEOUT_MS`, derived from it rather than
// typed beside it, and asserted in `test/eventActions.test.js`. Deriving it is
// the part worth copying — a constant typed twice drifts the first time somebody
// tunes the client and does not think to look here.
const BUDGET_MS = sidecar.TIMEOUT_MS + 3000
// How many beacons one step may ask for. A bound in the module, in front of the
// operator's cap rather than instead of it: this one is what the GAME can stand,
// and the cap is what this deployment allows. Pre-checking here is what lets a
// dry run show an author the refusal rather than a run meeting it at 3am.
const MAX_BEACONS = 25
// Statuses the far end uses to mean "this will never work". Everything else —
// including a timeout, a transport error and anything unrecognised — is left to
// the default, which is a retry. That direction is deliberate: see the envelope
// note on `classify` below.
const PERMANENT = new Set(['unknown-command', 'no-idempotency-key'])
/**
* One place that turns a client reply into an envelope core understands.
*
* Worth having as a function even with two callers. The rule it encodes —
* "unrecognised means retry" — is the one you want stated once, because the
* failure mode of getting it wrong per-action is a verb that quietly stops
* retrying and nobody notices until a shard reboots mid-event.
*/
function classify(answer) {
// **The field is `error`, not `detail`.** Core's dispatcher reads exactly two
// things off a failure envelope — `ok` and `retry` — and passes `error`
// through as the message an operator sees on the run console and an author
// sees on a dry run. Anything under another name is dropped in silence, so an
// action that puts its reason in `detail` produces a refusal that reads
// "<action id> refused" and tells nobody why. Writing this template is how
// that was found: `EVENTS.md` §H names a `detail` member in passing and core
// has never read one.
return { ok: false, retry: !PERMANENT.has(answer.status), error: answer.status }
}
// ══ BUDGETS ═══════════════════════════════════════════════════════════════
//
// A dimension core can count and bound. Core never learns what a beacon is: it
// holds `{ dimension, consumed, cap }` and the vocabulary stays here. That is the
// whole of what makes the engine game-agnostic at this seam.
//
// **Declaring a dimension is not the same as bounding it.** A declared dimension
// with no operator cap is counted and unbounded — which is useful on its own,
// because the run console then shows an author what their event actually spent.
const BUDGETS = [
{ id: 'examplegame.beacons', label: 'Beacons lit', unit: 'count' },
]
// ══ OPTION SOURCES ════════════════════════════════════════════════════════
//
// What a dropdown on the authoring form is filled from. A fourth registration
// rather than a field on the action, because a catalog usually has more than one
// consumer — this one answers both the action's `clanId` param and the lease's
// target would, if the lease were targeted — and two actions declaring it
// separately would be two allowlists that can disagree.
//
// **A source that refuses degrades its field to free text with a warning.** It
// never blocks the form and it never raises, so this resolver may read the
// database and may fail. Do not defend against that by returning a hardcoded
// list; an empty answer with a log line is more honest than a stale one.
const OPTION_SOURCES = [
{
id: 'examplegame.options.clans',
label: 'Clans',
// Core passes `q` to EVERY source and requires it of none, so a resolver
// written before search existed behaves identically. Declare
// `searchable: true` when the term actually narrows the answer — the form
// reads it to choose between a typeahead and a select. Do not infer it from
// the length of the list: that reads correctly right up until a small
// deployment's list happens to fit in a dropdown.
async resolve() {
try {
const clans = await clanDb.listClans()
return clans.map((c) => ({ value: c.externalId, label: c.name }))
} catch (err) {
log.warn('option source failed', { source: 'examplegame.options.clans', error: err.message })
return []
}
},
},
]
// ══ LEASES ════════════════════════════════════════════════════════════════
//
// A value a run BORROWS and gives back. The module declares what can be held and
// how long; **the verb is core's** — an author puts `core.lease` in a step, and
// core reads the baseline, reserves the target in its resource ledger, applies
// the value with a deadline, and restores it at teardown through `restore()`
// below. A lease verb of your own would be that duration bound and that
// two-events-one-target check re-implemented once per module, advisory
// everywhere, and wrong in the first one that forgot it.
//
// **Only advertise a lease you have verified takes effect.** A value your game
// reads once at start-up and caches will apply cleanly, read back cleanly and do
// nothing — a capability that lies, which no amount of core-side checking can
// catch. Apply it, observe it, restore it, as a test, per key.
const LEASES = [
{
id: 'examplegame.rate.gather',
label: 'Gather rate',
type: 'float',
min: 0.5,
max: 5,
// The longest core will let a run hold it. A weekend, here. The bound is
// core's to enforce and yours to choose, and it should be the longest you
// would be comfortable finding still applied after everything else broke.
maxDurationMs: 48 * 60 * 60 * 1000,
/** The baseline, read live. Core stores what this answers and restores to it. */
async read() {
// `ask`, not `send`. A read carrying an idempotency key would be answered
// with the FIRST read's value forever — see `sidecarClient.js`'s header.
const answer = await sidecar.ask('rate.gather.read')
return answer.ok ? { ok: true, value: answer.data.value } : classify(answer)
},
/**
* Hold the value until `until`.
*
* **`until` goes down the wire and the far end honours it without being asked
* again.** Core's copy of the deadline is for the console; the game's copy is
* the fail-safe. A module that passes it and then relies on core to come back
* and restore has built a lease that outlives an outage — which is the one
* thing a lease exists to prevent.
*/
async apply(value, until) {
// No idempotency key, and that is deliberate rather than an omission:
// setting a value to X twice is setting it to X. A key here would buy
// nothing and cost the reply's freshness.
const answer = await sidecar.send('rate.gather.apply', {
value,
until: until instanceof Date ? until.toISOString() : until,
})
return answer.ok ? { ok: true } : classify(answer)
},
/**
* Put it back.
*
* `expected` is what core believes is currently applied. Answering that the
* live value differs is how a lease lands `drifted` with the current value
* beside it, rather than core silently overwriting whatever a human changed
* mid-event. Restoring must be idempotent for the same reason `revert` must:
* core may ask more than once.
*/
async restore(baseline, { expected } = {}) {
const live = await sidecar.ask('rate.gather.read')
if (!live.ok) return classify(live)
if (expected !== undefined && Number(live.data.value) !== Number(expected)) {
return { ok: true, drifted: true, value: live.data.value }
}
const answer = await sidecar.send('rate.gather.restore', { value: baseline })
return answer.ok ? { ok: true } : classify(answer)
},
/**
* A FOURTH question, not a fourth spelling of `read()`.
*
* "Does the game side still have any record of this hold?" A value that
* DIFFERS from what the run applied is drift, which `restore()` reports; a
* reconcile that inferred absence from a changed value would take the row out
* and tell an operator the lease vanished rather than that somebody moved it.
*
* Optional, and answering `{ ok: true, held: false }` is the only thing that
* takes a lease's ledger row out. Everything else — a throw, a refusal, no
* `inForce` at all — leaves the row alone, which is the same
* "I do not know is never it is gone" rule the actions below follow.
*/
async inForce() {
const live = await sidecar.ask('rate.gather.read')
if (!live.ok) return classify(live)
return { ok: true, held: Number(live.data.value) !== 1.0 }
},
},
]
// ══ ACTIONS ═══════════════════════════════════════════════════════════════
const ACTIONS = [
{
id: 'examplegame.beacon.light',
label: 'Light beacons',
description: "Lights signal beacons at a clan's hall for the length of this event.",
// Both are closed sets core interprets, and neither is decoration: `risk`
// decides which role may put this in a step and whether it is off by default,
// and `reversible` decides whether core will ever call `revert`.
risk: 'change', // notify | inspect | change | irreversible
reversible: 'ledger', // none | self | ledger | override
version: 1,
budgetMs: BUDGET_MS, // TRAP 1 — see the constant
// ── TRAP 4 ──────────────────────────────────────────────────────────
//
// What ONE invocation consumes. A function, because it depends on the params.
//
// **Core prices this BEFORE dispatch and never reconciles it against what
// came back.** There is no check that the `resources` you return match what
// you said you would spend — there cannot be, since core does not know what a
// beacon is. So an action that returns `{ 'examplegame.beacons': 1 }` while
// lighting twelve turns an operator's cap of 30 into a cap of 360, and the
// meter on the run console agrees with the lie. Nothing goes red. The first
// symptom is a world with an order of magnitude more in it than anyone
// authorised.
//
// Count what you will actually make, from the params you were given, every
// time. If you cannot know until the answer comes back, declare the maximum:
// a spend that is too high refuses an event that would have fit, which an
// author can see and argue with, and one that is too low cannot be seen at all.
//
// A `cost()` naming a dimension no module declared is REFUSED — at save, at
// the dry run and at dispatch — because the fix is a module's declaration and
// not a deployment's cap.
cost: (p) => ({ 'examplegame.beacons': Number(p.count) || 0 }),
params: [
{
name: 'clanId',
type: 'string',
required: true,
// `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 unreconstructable afterwards by anybody who did not write the action.
example: 'clan-1',
source: 'examplegame.options.clans',
},
{ name: 'count', type: 'int', required: true, example: 6 },
],
/**
* Do it.
*
* @param {object} env
* @param {string} env.runId
* @param {string} env.stepId
* @param {string} env.idempotencyKey a function of identity, never of attempt
* @param {*} env.scope opaque to core; may be null
* @param {object} env.params
* @param {object} env.actor
* @param {boolean} env.verify dry run: validate, change NOTHING
*/
async perform({ idempotencyKey, params, verify }) {
const count = Number(params.count)
if (!Number.isInteger(count) || count < 1 || count > MAX_BEACONS) {
// A refusal the second attempt would repeat verbatim, so `retry: false`.
// This is the arm TRAP 1 exists to keep reachable.
return { ok: false, retry: false, error: `count must be 1..${MAX_BEACONS}` }
}
// **`verify` must change nothing and must answer honestly.** It rides this
// same dispatcher a real run uses, because a dry run down a second code
// path is a dry run of the second path. Validate everything you can reach
// without writing — the params above, and a lookup below — then stop.
if (verify) {
const clan = await clanDb.findClan(params.clanId)
return clan
? { ok: true }
: { ok: false, retry: false, error: `no such clan: ${params.clanId}` }
}
// ── TRAP 2 ────────────────────────────────────────────────────────
//
// The key goes through, unchanged. Core derives it from the step's identity
// and never from the attempt number, so every retry carries the same one —
// and the far end, which is the only end that can tell a retry from a
// repeat, answers a key it already executed with the ORIGINAL reply rather
// than running it again.
//
// A module that generates its own key here, or drops it, has an action that
// cannot be retried safely, and the cost of that is not a failed step: it
// is a second world change on a socket hiccup. It looks like it works in
// every test, because in every test the first attempt succeeds.
const answer = await sidecar.send(
'beacon.light',
{ clanId: params.clanId, count },
{ idempotencyKey },
)
if (!answer.ok) return classify(answer)
// What core writes into its ledger. `kind` is yours; `ref` is whatever you
// will need to undo it. The boot stamp rides along because `reconcile`
// below is the only thing that reads it — see its note.
return {
ok: true,
resources: answer.data.refs.map((ref) => ({
kind: 'beacon',
ref,
meta: { bootId: answer.data.bootId },
})),
}
},
/**
* Undo it. **Required, because `reversible` is `'ledger'`.**
*
* Called by core's cleanup sweep at teardown, over the rows this action's
* `resources` produced — a LIST, so twelve beacons are one round trip rather
* than twelve. Cleanup is derived rather than authored: there is no
* `on_teardown` and no cleanup phase in a spec, because an operator cannot be
* relied on to write the undo and an aborted run never reaches the phase they
* wrote it in. It runs on every terminal path — completion, cancellation and
* abort alike.
*
* ── TRAP 3 ──────────────────────────────────────────────────────────
*
* Two things follow from `EVENTS.md` §D rule 1, *core records a resource
* BEFORE it is confirmed*:
*
* • **Reverting something that does not exist is a SUCCESS.** A dispatch
* whose answer was lost leaves a ledger row for something that may never
* have existed, and cleanup will ask about it. You must never have to
* tell "I removed it" from "it was not there" — and you could not, because
* the far end cannot either. Answer `{ ok: true }`.
*
* • **You will be called with NO resources and only a key.** That is the
* lost-answer case stated exactly: core knows a dispatch went out under
* this key and never learned what it made. A module that can undo by key
* answers honestly. One that cannot answers `{ ok: false }`, and the row
* stays visible to an operator — which is the correct outcome, not a
* silent one. Answering `{ ok: true }` to a question you cannot answer is
* how a beacon burns forever with core's ledger reporting it cleaned up.
*
* And it must be idempotent, because core may ask more than once.
*/
async revert({ resources, idempotencyKey }) {
const refs = (resources || []).map((r) => r.ref).filter(Boolean)
if (refs.length === 0) {
// The lost-answer case. This module CAN answer it, because the far end
// stores what each key produced — so asking it to undo the key is a real
// question with a real answer. If yours cannot, say `{ ok: false }` here
// and let a human see the row.
const byKey = await sidecar.send('beacon.douse', { refs: [] }, { idempotencyKey })
return byKey.ok ? { ok: true } : classify(byKey)
}
const answer = await sidecar.send('beacon.douse', { refs }, { idempotencyKey })
if (!answer.ok) return classify(answer)
// `{ ok: true }` reverts the whole group. Name the ones that did not come
// back in `failed: [...]` and core keeps exactly those rows.
return { ok: true }
},
/**
* Which of these does the game still have? **Optional.**
*
* Asked after something outside core restarted — core's own boot, or this
* module calling `core.reconcileEvents()` because it saw the boot id change.
*
* The asymmetry with `revert` is the design: a module that cannot say what the
* game still has is not broken, and core keeps believing its own ledger. One
* that created something and cannot undo it has made a promise core has no way
* to keep. So `revert` is required and this is not.
*
* **Anything that is not an explicit `{ ok: true, inForce: [...] }` leaves the
* ledger alone.** "I do not know" is never read as "it is gone", and a
* resource reported missing becomes `orphaned` rather than `reverted` —
* because nobody asked for it to go.
*/
async reconcile({ resources }) {
const refs = (resources || []).map((r) => r.ref).filter(Boolean)
// A question, so `ask`. Keying this one would have pinned the answer to
// whatever was in force the first time core ever swept — which is the exact
// opposite of what a reconcile is for.
const answer = await sidecar.ask('beacon.inForce', { refs })
if (!answer.ok) return classify(answer)
return { ok: true, inForce: answer.data.refs }
},
},
]
module.exports = { BUDGETS, OPTION_SOURCES, LEASES, ACTIONS, BUDGET_MS, MAX_BEACONS, classify }

View File

@@ -91,6 +91,38 @@ module.exports = {
// stack. Routers are built inside `register()`, so `ctx` is set by then.
get middleware() { return need().middleware },
// Firing a declared event (MODULE_API.md §2.3). Wrapped as a call rather than
// exposed as `get events()`, so that `require('../core').emit` taken at file
// scope still resolves `ctx` at call time like everything else here.
//
// **It returns nothing, and in production it never throws at the caller.** The
// emit is the end of this module's involvement: core validates the payload
// against the declared contract, decides which rules match, resolves who they
// reach and sends. A module cannot address a person, choose a channel or write
// a subject line, and this seam is deliberately too narrow to try (§2.7).
//
// Outside production a bad payload throws here rather than being logged, which
// is the point: you meet the mismatch in your own tests instead of in an
// operator's log six weeks later.
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope),
// Telling core the game restarted (MODULE_API.md §2.3, 1.10.0). The one thing
// the event contract adds to `ctx`, and it is here for a reason worth carrying:
// **core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
// and cannot tell a wedged sidecar from a shard that rebooted and lost every
// creature an event spawned. Only this module knows, because only this module
// watches the feed the boot id arrives on.
//
// Calling it asks core to sweep its resource ledger and put the question back
// to this module's actions, as `reconcile({ runId, resources })`. Fire and
// forget: it returns at once and the sweep happens on core's own time.
//
// See `boot.js` for the watch that calls it, and `config/eventActions.js` for
// the answer. Named longer than the `ctx` member it wraps because this object
// is flat — `core.emit` is already a little ambiguous and `core.reconcile()`
// would be worse, since a module has more than one thing it could reconcile.
reconcileEvents: () => need().events.reconcile(),
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
// only correct way to find a file you shipped, because the working directory is
// core's and the module's location is the loader's business.

View File

@@ -50,6 +50,7 @@ module.exports = function register(ctx, api) {
const worldRouter = require('./router/public/world.router')
const clansRouter = require('./router/public/clans.router')
const clanProvider = require('./model/clans/clanProvider.model')
const eventActions = require('./config/eventActions')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -103,6 +104,190 @@ module.exports = function register(ctx, api) {
// optional ones is an edit to the provider and not to this file.
api.registerTeamProvider(clanProvider)
// ── Engagement: declaring what your game can announce ────────────────────
//
// The three calls below are one seam, and it is the one where a module is most
// tempted to reach past the boundary. **You declare what CAN happen; core
// decides who is told.** A module never names a person, a channel or an
// address, and never sends anything (MODULE_API 1.7.0 and 1.9.0; §2.7).
//
// A TRIGGER is not a notification stream, and the two are easy to confuse
// because both are catalogs of things that happen. A stream is a subscribe
// toggle you publish to yourself. A trigger is a PAYLOAD CONTRACT an operator
// writes rules against — it says what variables the event carries and how wide
// an audience it may ever be given, and core does the sending. Their ids share
// one namespace, so declaring both for one id is legal and is one event with a
// toggle and a contract; taking an id another module owns is not.
api.registerEventTriggers([
{
id: 'examplegame.world.status_changed',
label: 'World came up or went down',
description: 'The game server changed between online and offline.',
kind: 'event',
// The cooldown subject: "once per world", not "once per user". It must
// NAME one of the variables below — core refuses the registration
// otherwise, with this trigger's id in the message, and the module does not
// load. That check exists because the failure it prevents is silent: a
// subjectKey naming nothing keys every subject on `undefined`, which looks
// exactly like the feature working right up until two worlds share it.
subjectKey: 'worldName',
audience: 'authenticated', // what a rule is CREATED with
// ...and the widest it may EVER be given. Required, with no default,
// because there is no safe value to guess: `owner` would silently break a
// broadcast and `authenticated` would silently widen a staff-only event.
// The values are ordered by CONTAINMENT, not by size — see chapter 2.
ceiling: 'authenticated',
version: 1,
variables: [
// Every variable needs an `example`, and it is not decoration: it is what
// lets an operator preview and test-send a template without waiting for a
// real game event, which is the reason template systems ship untested.
{ name: 'worldName', type: 'string', required: true, example: 'Example World' },
{ name: 'status', type: 'string', required: true, example: 'online' },
{ name: 'players', type: 'int', required: false, example: 42 },
// A `url` is validated SITE-RELATIVE, because it ends up in an href in a
// mail somebody opens days later. Never a full URL of your own.
{ name: 'url', type: 'url', required: false, example: '/world' },
],
},
])
// An AUDIENCE is a named set of PEOPLE this module can resolve over its own
// data, for an operator to point a rule at. "This clan's members" is one;
// "everyone who opened the last mail" is not, and nothing here builds it.
//
// **The resolver returns user ids and nothing else.** It is not handed a
// template, a channel or an address and it cannot enumerate them — core maps
// ids to addresses on its own side, after preferences, suppression and the
// verification gate. That is what stops this becoming the back door §2.7 spends
// a section closing.
//
// Audiences are their OWN id space, unlike triggers and streams: an audience
// names a set of people and a trigger names an event, so the two may share a
// name without colliding.
api.registerAudiences([
{
id: 'examplegame.clan.members',
label: 'Members of a clan',
// `int` or `string` only, and CONSTANT — an operator fills these in when
// they save the rule. There is no way to say "the clan this event was
// about"; if a rule needs that, the EVENT carries its own recipients
// instead. Finding that out late is a phase's worth of rework.
params: [{ id: 'clanId', type: 'string', required: true }],
ceiling: 'members',
resolve: async ({ clanId }) => clanProvider.listClanMemberUserIds({ clanId }),
},
])
// Finally the CONTENT: the bodies your messages use, and the rules that decide
// when one is sent. Both arrive **switched off** — `enabled` is not a parameter
// and there is no call that sets it. An operator turns a module's mail on;
// installing a module never does.
//
// The two halves have different lifetimes, and the asymmetry is the contract:
//
// • **Templates are re-ensured on every boot**, under `seedVersion`, so
// improving a default body reaches deployments that never edited it — and
// one an operator HAS edited is marked customized and left alone. Bump
// `seedVersion` when the body changes; never for a comment.
// • **Rule groups are offered ONCE, per named group key.** Re-offering would
// resurrect a rule an operator deleted and reset one they enabled. So a rule
// appended to an existing group reaches FRESH INSTALLS ONLY. That is the
// guarantee rather than a limitation to work around: a rule that has to
// reach existing deployments takes a NEW group key, and you choose that
// knowingly because you name the groups.
//
// Core's generic bodies are a first-class answer, not a fallback: point a
// channel at `notify.event` / `inapp.event` / `notify.digest` and author
// nothing. Ship a body of your own when the message has something to say that a
// structural projection of the payload cannot. Below, the mail does — a world
// coming back deserves a sentence — and the in-app item does not, so it uses
// core's.
//
// Note the two casings, which are not a slip: a TEMPLATE is an object this call
// shapes (`triggerId`), and a RULE is a row (`trigger_id`). Copy them as they
// are.
api.registerEngagementSeeds({
templates: [
{
// MUST be namespaced `<moduleId>.` — the key column is unique across the
// whole table, and an unprefixed `notify.event` from a module would
// collide with core's own body and win.
key: 'examplegame.world-status-changed',
name: 'World — status changed',
channel: 'email',
subject: '{{worldName}} is {{status}}',
triggerId: 'examplegame.world.status_changed',
triggerVersion: 1,
seedVersion: 1,
// The same block objects the template editor writes, so an operator can
// open this in the admin panel and keep editing from here.
//
// **This is the one thing in this file core does not check for you.**
// `registerEngagementSeeds` asserts that `blocks` is a non-empty array and
// stops; the BODY is validated by the block registry, which runs in the
// editor and in the renderer. So a malformed block registers, seeds, and
// first shows itself when an operator opens the body or a rule fires.
// Two that are easy to get wrong: every block carries its own `id`, and
// `email.heading`'s `level` is 'h1' | 'h2' | 'h3' — not a number.
blocks: [
{ id: 'h', type: 'email.heading', props: { level: 'h2', text: '{{worldName}} is {{status}}' } },
{ id: 'intro', type: 'email.text', props: { text: 'There are {{players}} players online right now.' } },
{ id: 'cta', type: 'email.button', props: { label: 'Open the world page', url: '{{url}}' } },
],
},
],
ruleGroups: [
{
key: 'world-v1',
note: 'the world status rule, seeded once',
rules: [
{
trigger_id: 'examplegame.world.status_changed',
name: 'World status changes',
audience: 'authenticated',
channels: ['email', 'inapp'],
template_keys: {
email: 'examplegame.world-status-changed',
inapp: 'inapp.event',
},
// Two ceilings on volume, and they answer different questions. The
// cooldown is per SUBJECT — one mail per world per hour, however many
// times it flaps. The hourly cap is per RULE, and is the thing that
// keeps a misconfiguration from becoming a mail storm.
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
],
},
],
})
// ── Events: what a scheduled event may do to your game ───────────────────
//
// MODULE_API 1.10.0, `EVENTS.md` §F, and chapter 5 of this kit. Four
// declarations, and the whole of the file they come from is about the four
// rules that are invisible until an outage.
//
// **This is core CALLING YOU**, like the Team provider above and unlike
// everything else in this function — but from further away than either, because
// the thing on the other end is a game server. That distance is the reason an
// action declares `budgetMs` and the reason its failure default is a retry.
//
// **Every one of the four is optional.** A module that registers none of them
// leaves its deployment with an event engine that can announce, wait, cue a
// human and publish results, which is a working product. Each one *adds* what
// an author can reach for; none is load-bearing for the engine.
//
// Registered in this order because it is the order they depend on each other:
// an action's `cost` may only name a budget some module declared, and a param's
// `source` names an option source. Core resolves both after every module has
// registered, so the order here is for a reader rather than for the loader.
api.registerEventBudgets(eventActions.BUDGETS)
api.registerEventOptionSources(eventActions.OPTION_SOURCES)
api.registerEventLeases(eventActions.LEASES)
api.registerEventActions(eventActions.ACTIONS)
// 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
// that must not serve traffic until it has warmed a cache gets that for free.

View File

@@ -82,4 +82,25 @@ async function replaceClan({ externalId, name, abbr, memberCount, members }) {
}
}
module.exports = { listClans, findClan, listMembers, replaceClan, CLANS, MEMBERS }
/**
* The site accounts behind one clan's roster — the whole of an audience resolver.
*
* `user_id` is NULL for most characters, and the filter is the point: an audience
* resolves to PEOPLE WITH ACCOUNTS, and a character nobody has linked is not one.
* Returning its NULL would hand core a hole in an array it is about to mail.
*
* DISTINCT because one person may hold several characters in the same clan, and
* the resolver's contract is a set of users rather than a list of characters.
* Without it a three-character player is told three times.
*/
async function listMemberUserIds(clanId) {
const rows = await core.query(
`SELECT DISTINCT user_id AS userId
FROM ${MEMBERS}
WHERE clan_id = ? AND user_id IS NOT NULL`,
[clanId],
)
return rows.map((r) => r.userId)
}
module.exports = { listClans, findClan, listMembers, listMemberUserIds, replaceClan, CLANS, MEMBERS }

View File

@@ -292,6 +292,36 @@ async function projectRoster(externalId, members, viewer) {
// a page that does not exist, which is worse.
const pageUrlTemplate = '/examplegame/clans/{externalId}'
// ── The audience resolver ─────────────────────────────────────────────────
//
// Registered in `index.js` as `examplegame.clan.members` and called by core when
// a rule pointed at that audience fires. It lives beside the provider because it
// answers a question about the same rows, and it is NOT part of the provider —
// core calls it through the audience registry, not through the five members
// above.
//
// **Three rules, and every one of them protects somebody's mailbox rather than
// this module's correctness.**
//
// 1. Return user ids and nothing else. You are not handed a template, a channel
// or an address, and you may not enumerate them; core maps ids to addresses
// on its own side, after preferences, suppression and the verification gate.
// 2. Never widen on failure. A resolver that cannot answer returns the EMPTY set
// — never "everyone", never the last good answer. Core treats a throw the
// same way, but doing it here is what lets the log say which clan.
// 3. It is a SET of people, not a list of characters. The `DISTINCT` is in the
// query for that reason (see `clanProvider.db.js`).
async function listClanMemberUserIds({ clanId }) {
try {
return await db.listMemberUserIds(clanId)
} catch (err) {
log.warn('could not resolve clan members; resolving to nobody', {
clanId, message: err.message,
})
return []
}
}
module.exports = {
getTeams,
getTeamMembers,
@@ -300,4 +330,5 @@ module.exports = {
rosterVisibleTo,
pageUrlTemplate,
gameIsReachable,
listClanMemberUserIds,
}

View File

@@ -0,0 +1,285 @@
// ── The near end of a call whose far end is your game ─────────────────────
//
// Every other file in this module reads its own tables. This one is different in
// kind: it is the only place that *asks the game to do something* and waits for
// an answer. That makes it the file chapter 5 is mostly about, and the file a
// reviewer should read hardest.
//
// **The transport is simulated and everything around it is not.** `deliver()` at
// the bottom is the one function you replace, and until you do, this module talks
// to a fake game that lives in this process. What is real is the shape: a
// declared timeout, an idempotency key that goes down the wire, a far end that
// executes a key at most once, a reply that says which of those two happened, and
// a call that answers rather than throwing. Those are the parts the event
// contract depends on, and simulating them is how the kit's CI can prove them at
// all — there is no game server on a runner.
//
// ── Why this file is not called `gameClient` ──────────────────────────────
//
// The website process never opens a connection to a game server (MODULE_API.md
// §2.7). It opens one to YOUR SIDECAR, which owns the socket to the game — see
// chapter 3. `test/noGameConnection.test.js` enforces the narrow, decidable half
// of that rule and its header names this exact filename as the one you allow when
// you replace `deliver()`:
//
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
//
// So the moment this file grows a real transport, that test fails correctly, and
// the fix is one line in a file whose whole job is to name what may reach the
// network. Do not delete the check to make it pass.
//
// ── TIMEOUT_MS is not a tuning knob. It is half of a rule. ────────────────
//
// An event action declares `budgetMs`, and core's dispatcher enforces it: when
// the budget expires it stops waiting and classifies the failure as **retry**,
// unconditionally, without asking the action — it cannot ask, the action is still
// awaiting a socket.
//
// So if core's deadline is shorter than this one, your action never gets to
// classify its own failure, and `{ ok: false, retry: false }` in your envelope is
// unreachable code. `budgetMs` must EXCEED the timeout of whatever the action
// talks to. This constant is exported so `config/eventActions.js` can be written
// against it rather than beside it, and so a test can assert the ordering — which
// it does, because the first module this project shipped got it the wrong way
// round and retried a verb it had explicitly refused.
//
// ── The at-most-once store belongs to the FAR end ─────────────────────────
//
// The simulation below keeps a map of keys it has already executed, and that map
// stands in for state on the game side, not for state here. A store on this side
// would be a module remembering what it sent, which answers nothing: the case
// that matters is the one where the command arrived, ran, and the acknowledgement
// was lost. Only the end that ran it can tell a retry from a repeat.
//
// Your sidecar and your plugin are where that store goes; chapter 4 is about
// building it. What this file owes the contract is narrower and is the thing
// modules get wrong: **pass the key through, unchanged, on every attempt.**
//
// ── `ask` and `send` are two functions because a key is not for a question ─
//
// This file offers `ask()` for a read and `send()` for a write, and the split is
// not tidiness — it is the correction that writing this template produced.
//
// The first draft had one function and every call carried a key, including the
// reads. That is wrong in a way that is quiet and total: the far end answers a
// key it has already executed with the ORIGINAL reply, so the second read of a
// value returns the first read's answer, and the third, and every one after it
// forever. The lease applied correctly, the game changed correctly, and this
// module could no longer see any of it — `read()` reported the baseline it had
// found before the run started and `inForce()` said nothing was held.
//
// **An idempotency key makes a COMMAND safe to repeat. It makes a QUESTION
// permanently stale.** Anything that only asks must go through `ask`.
//
// The rule for which commands need one is narrower than "all of them", too. A key
// is for a write whose repetition would be a second EFFECT — creating something,
// granting something, announcing something. A write that SETS a value to X is
// idempotent by its own nature: doing it twice is doing it once, and a key would
// only pin its reply. So the lease's `apply` and `restore` send no key, and the
// beacon verbs send core's.
const core = require('./core')
const log = core.logger('sidecar')
/**
* How long this client waits before giving up on the far end.
*
* Read the header. Every action in `config/eventActions.js` declares a `budgetMs`
* strictly greater than this, and `test/eventActions.test.js` asserts it.
*/
const TIMEOUT_MS = 12000
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
return { ok, status, data }
}
/**
* Ask the game a question.
*
* **Never carries an idempotency key**, and the reason is the header's last
* section: a key would make the far end answer every future call with the first
* one's answer. A read is cheap to repeat and there is nothing to make safe.
*/
async function ask(command, payload = {}) {
return roundTrip(command, payload, null)
}
/**
* Tell the game to do something and wait for its answer.
*
* @param {string} command
* @param {object} payload
* @param {object} [options]
* @param {string} [options.idempotencyKey] core's key for this step. Pass it
* through unchanged on every attempt. Omit it only for a write that is
* idempotent by its own nature — setting a value to X.
*/
async function send(command, payload = {}, { idempotencyKey = null } = {}) {
return roundTrip(command, payload, idempotencyKey)
}
/**
* One round trip, with this client's own deadline on it.
*
* **Never throws.** A module that let a socket failure escape into core's dispatch
* would be handing core an exception where the contract asked for a verdict — and
* core would classify it as a retry, which is the safe default but not always the
* right one. Answer, and let the action decide.
*/
async function roundTrip(command, payload, idempotencyKey) {
let timer = null
try {
return await Promise.race([
deliver(command, payload, idempotencyKey),
new Promise((resolve) => {
timer = setTimeout(() => resolve(reply(false, 'timeout')), TIMEOUT_MS)
}),
])
} catch (err) {
// Everything the far end can do to us, reduced to one verdict. The status is
// the thing an action's `classify` reads; the stack goes to the log, where a
// human can find it, and never into a reply core would store.
log.error('command failed', { command, error: err.message })
return reply(false, 'transport-error')
} finally {
if (timer) clearTimeout(timer)
}
}
// ══════════════════════════════════════════════════════════════════════════
// Everything below this line is the FAKE GAME. Delete it, and make `deliver()`
// one request to your sidecar carrying `command`, `payload` and the key.
// ══════════════════════════════════════════════════════════════════════════
/**
* The far end's at-most-once store: key → the reply the first attempt produced.
*
* On the game side this is persisted, because the case it exists for is a restart
* mid-run. Here it is a Map, and losing it on restart is exactly what makes
* `bootId` below meaningful.
*/
const executed = new Map()
/** What the fake game currently holds. A restart resets both. */
let bootId = `boot-${Date.now()}`
let gatherRate = 1.0
const lit = new Set()
/**
* Stand-in for one round trip to your sidecar.
*
* **REPLACE THIS FUNCTION AND NOTHING ELSE.** Its contract is the whole of what
* the rest of this module assumes:
*
* • it resolves rather than rejecting, with `{ ok, status, data }`;
* • it is given the idempotency key and sends it unchanged;
* • a key it has already executed answers with the ORIGINAL reply, restamped —
* never by running the command again;
* • a key it is still working on answers `busy`, which is transient by
* construction: the work is happening.
*/
async function deliver(command, payload, idempotencyKey) {
// **The far end refuses an unkeyed command it cannot safely repeat.** This is
// the game side protecting itself rather than trusting every caller to have
// read the contract, and it is worth building: the module that forgets to pass
// the key is not punished on the first attempt, which succeeds, but on the
// retry six weeks later that makes a second set of everything.
if (CREATES.has(command) && !idempotencyKey) return reply(false, 'no-idempotency-key')
if (idempotencyKey && executed.has(idempotencyKey)) {
// The whole point. A retry of a command whose acknowledgement was lost
// collects the answer the first attempt never delivered, and the world is
// changed once. Note it is the same `data`, not a fresh execution: a repeat
// that re-ran and returned a NEW serial would be two creatures in the world
// and one in core's ledger.
return { ...executed.get(idempotencyKey), repeat: true }
}
const answer = execute(command, payload)
if (answer.ok && idempotencyKey) executed.set(idempotencyKey, answer)
return answer
}
/**
* The commands whose repetition would be a second effect.
*
* Everything else here either asks a question or sets a value, and both are
* idempotent without help. Your game's list is the verbs that CREATE, GRANT or
* ANNOUNCE — the ones where doing it twice is visible in the world.
*/
const CREATES = new Set(['beacon.light'])
/** The fake game's verbs. Yours are your game's, and none of them are these. */
function execute(command, payload) {
switch (command) {
case 'beacon.light': {
const refs = []
for (let i = 0; i < payload.count; i += 1) {
const ref = `beacon:${payload.clanId}:${lit.size + 1}`
lit.add(ref)
refs.push(ref)
}
return reply(true, 'ok', { refs, bootId })
}
case 'beacon.douse': {
// Dousing something that is not lit is a SUCCESS. See the revert rule in
// `config/eventActions.js`: core records a resource before it is confirmed,
// so cleanup will ask about things that may never have existed, and a
// module must never have to tell "I removed it" from "it was not there".
for (const ref of payload.refs || []) lit.delete(ref)
return reply(true, 'ok', {})
}
case 'beacon.inForce':
// Which of these does the game still have? Answered from live state, which
// is why a restart (`lit` empty again) reports honestly rather than
// repeating what the caller already believed.
return reply(true, 'ok', { refs: (payload.refs || []).filter((r) => lit.has(r)) })
case 'rate.gather.read':
return reply(true, 'ok', { value: gatherRate })
case 'rate.gather.apply':
// `until` arrives and the far end is responsible for it WITHOUT being asked
// again. A real plugin arms a timer that restores the baseline when the
// deadline passes, and re-arms it at load if the value is in the world save.
// A far end that treats `until` as advisory has produced a lease that
// outlives an outage, which is the one thing a lease exists to prevent.
gatherRate = payload.value
return reply(true, 'ok', { value: gatherRate, until: payload.until })
case 'rate.gather.restore':
gatherRate = payload.value
return reply(true, 'ok', { value: gatherRate })
default:
// An unknown command is the far end's judgement that this will never work,
// and it is the one status an action turns into `retry: false`.
return reply(false, 'unknown-command')
}
}
/**
* Pretend the game restarted. Test seam, and the only reason it is exported.
*
* A real module learns this from its sidecar — a boot id on the feed that changed,
* which is how you tell a game restart from a sidecar reconnect. `boot.js` is
* where that watch lives, and `ctx.events.reconcile()` is what it calls.
*/
function simulateRestart() {
bootId = `boot-${Date.now()}-${Math.random().toString(16).slice(2)}`
executed.clear()
lit.clear()
gatherRate = 1.0
return bootId
}
/** The boot id the far end is currently reporting. */
function currentBootId() {
return bootId
}
module.exports = { TIMEOUT_MS, ask, send, simulateRestart, currentBootId }

View File

@@ -49,6 +49,15 @@ function fakeCtx(overrides = {}) {
return log
},
auth: { getUserFromRequest: spy(null) },
// The engagement seam (§2.3). One method, recording, because that is the
// whole of what a module may do with it: fire a declared event and stop.
// Core's own emit is fire-and-forget and returns nothing, so this does too —
// a fake that returned a receipt would invite a module to wait on one.
// `reconcile` joined it at 1.10.0 — the ONE thing the event contract adds to
// `ctx`, because an action is called BY core and is handed what it needs in
// the envelope. Only the module knows when the game restarted, so only the
// module can ask for the sweep.
events: { emit: spy(undefined), reconcile: spy(undefined) },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
@@ -85,7 +94,11 @@ function fakeCtx(overrides = {}) {
* an operator's install.
*/
function fakeApi() {
const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null }
const record = {
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
triggers: null, audiences: null, engagementSeeds: null,
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
}
const called = new Set()
const once = (name) => {
if (called.has(name)) throw new Error(`${name}() called twice`)
@@ -103,6 +116,16 @@ function fakeApi() {
// 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 },
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
// The event contract (1.10.0). `once` on all four: a batch is a module's
// COMPLETE statement about what it declares, so a second call is a module
// changing its mind halfway through `register()` rather than adding to it.
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View File

@@ -130,3 +130,224 @@ test('the manifest declares what the loader requires', () => {
// serves, so an entry in the module root would publish the whole module.
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
})
// ── The engagement seam ───────────────────────────────────────────────────
//
// Core validates most of what is declared here at registration, and a module
// that gets it wrong does not load. These tests are mostly NOT that validator
// restated: they are the rules a module can satisfy at boot and still have got
// WRONG in a way whose only symptom is mail somebody received. Where one does
// overlap core — the namespacing and subjectKey assertions below — it is because
// `npm test` is a cheaper place to meet the failure than a first boot, and the
// message here names the field.
test('every declared trigger is namespaced, ceilinged, and carries examples', () => {
const { api } = register()
const triggers = api.record.triggers
assert.ok(Array.isArray(triggers) && triggers.length, 'no triggers were declared')
for (const t of triggers) {
// Trigger ids and notification-stream ids are ONE namespace, so an id must
// carry this module's own prefix or it is a claim on somebody else's.
assert.ok(t.id.startsWith(`${manifest.id}.`), `${t.id} is not namespaced`)
// `ceiling` is required and has no default: there is no safe value to guess.
assert.ok(t.ceiling, `${t.id} declares no ceiling`)
// Core refuses this one too; failing it here just costs less. What the rule
// protects is the cooldown key — "once per world", not "once per user" — and
// a subjectKey naming nothing would key every subject on `undefined`.
const names = t.variables.map((v) => v.name)
assert.ok(names.includes(t.subjectKey), `${t.id}: subjectKey "${t.subjectKey}" is not a variable`)
for (const v of t.variables) {
// Not decoration: the example is what makes a template previewable and
// test-sendable without waiting for a real game event.
assert.ok('example' in v, `${t.id}.${v.name} has no example`)
// The type set is closed. A payload that needs a structure has outgrown
// interpolation, and a template cannot walk one.
assert.ok(
['string', 'int', 'float', 'boolean', 'datetime', 'url'].includes(v.type),
`${t.id}.${v.name} has type "${v.type}"`,
)
// A url is site-relative, because it ends up in an href in a mail somebody
// opens days later.
if (v.type === 'url') assert.match(v.example, /^\/[^/]/, `${t.id}.${v.name} must be site-relative`)
}
}
})
test('an audience resolves to nobody rather than to everybody when it fails', async () => {
const ctx = fakeCtx({ db: { query: () => Promise.reject(new Error('database is down')), pool: {} } })
const { api } = register(ctx)
const audience = api.record.audiences[0]
// The one behaviour worth a test of its own. Core treats a throw the same way,
// so this is not core's guard restated — it is the module choosing the same
// answer deliberately, and the reason is that the alternatives are both worse:
// "everyone" mails the wrong people and a stale answer mails yesterday's.
assert.deepStrictEqual(await audience.resolve({ clanId: 'clan-1' }), [])
})
test('a seeded rule names only this modules triggers, and its own or cores templates', () => {
const { api } = register()
const seeds = api.record.engagementSeeds
const ownKeys = new Set(seeds.templates.map((t) => t.key))
const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest'])
for (const t of seeds.templates) {
// The key column is UNIQUE across the whole table, so an unprefixed
// `notify.event` from a module would collide with core's body and win.
assert.ok(t.key.startsWith(`${manifest.id}.`), `template ${t.key} is not namespaced`)
// `protected` means "the system breaks without this body" — true of a
// password reset and of nothing a module ships. Core refuses a module
// template that sets it, because it would take an operator's delete button
// away.
assert.ok(!('protected' in t), `template ${t.key} may not mark itself protected`)
}
for (const group of seeds.ruleGroups) {
for (const rule of group.rules) {
assert.ok(
api.record.triggers.some((t) => t.id === rule.trigger_id),
`${rule.name} names a trigger this module does not declare`,
)
for (const key of Object.values(rule.template_keys)) {
assert.ok(ownKeys.has(key) || coreKeys.has(key), `${rule.name} names an unknown template ${key}`)
}
// `enabled` is not a parameter, and a value passed for it is ignored
// rather than refused. Passing one anyway states an intention the platform
// will not honour, so the honest thing is not to write it.
assert.ok(!('enabled' in rule), `${rule.name} may not seed itself enabled`)
}
}
})
test('every seeded body is a shape the block registry will accept', () => {
const { api } = register()
// The gap this exists for: `registerEngagementSeeds` checks that `blocks` is a
// non-empty array and stops. The BODY is validated by core's block registry,
// which runs in the template editor and in the renderer — so a malformed block
// registers, seeds, and first shows itself when an operator opens the body or a
// rule fires. Core is not here to ask, so assert the two rules that are easy to
// get wrong and impossible to notice.
const HEADING_LEVELS = ['h1', 'h2', 'h3']
for (const t of api.record.engagementSeeds.templates) {
const ids = new Set()
for (const block of t.blocks) {
// Every block carries its own id, unique within the body: it is how the
// editor addresses one block, and how `inapp.event`'s renderer maps blocks
// onto the inbox row's columns by role.
assert.ok(block.id && typeof block.id === 'string', `${t.key}: a block has no id`)
assert.ok(!ids.has(block.id), `${t.key}: two blocks share the id "${block.id}"`)
ids.add(block.id)
assert.ok(block.type.startsWith('email.'), `${t.key}: ${block.type} is not an email block`)
// A heading's `level` is a SIZE token, not a number. `{ level: 2 }` reads
// perfectly and is refused, and it renders at the default size in any
// preview that skips validation — which is the whole trap.
if (block.type === 'email.heading') {
assert.ok(
HEADING_LEVELS.includes(block.props.level),
`${t.key}: heading level "${block.props.level}" must be one of ${HEADING_LEVELS.join(', ')}`,
)
}
}
}
})
test('the world event fires on the transition and not on the poll', async () => {
const boot = require('../boot')
// Online already, and reporting online again. The refresh writes, and nothing
// is announced: this runs every thirty seconds, and a rule on an event fired
// every thirty seconds mails somebody every thirty seconds. Core's cooldown
// would hold — but leaning on it means emitting "still up" and calling it news.
const steady = fakeCtx({ db: { query: () => Promise.resolve([{ online: 1 }]), pool: {} } })
require('../core')._reset()
require('../core').init(steady)
await boot.refresh()
assert.deepStrictEqual(steady.events.emit.calls, [])
// Offline before, online now. One emit, with the declared payload.
const flipped = fakeCtx({ db: { query: () => Promise.resolve([{ online: 0 }]), pool: {} } })
require('../core')._reset()
require('../core').init(flipped)
await boot.refresh()
assert.strictEqual(flipped.events.emit.calls.length, 1)
const [triggerId, envelope] = flipped.events.emit.calls[0]
assert.strictEqual(triggerId, 'examplegame.world.status_changed')
assert.strictEqual(envelope.data.status, 'online')
// A fresh install, where there is no previous row at all. Not a change — and
// announcing it would tell everyone the world came online the first time an
// operator started the site.
const fresh = fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })
require('../core')._reset()
require('../core').init(fresh)
await boot.refresh()
assert.deepStrictEqual(fresh.events.emit.calls, [])
})
test('the four event declarations are registered, each exactly once', () => {
const { api } = register()
// Every one of the four is optional (§F), so this asserts what THIS module
// chose rather than what core requires. What it is really checking is that
// `index.js` still hands core the arrays `config/eventActions.js` exports —
// the failure it catches is a rename on one side and not the other, which
// costs a deployment a capability with nothing red anywhere.
assert.ok(Array.isArray(api.record.eventBudgets))
assert.ok(Array.isArray(api.record.eventOptionSources))
assert.ok(Array.isArray(api.record.eventLeases))
assert.ok(Array.isArray(api.record.eventActions))
// `once` on all four: a batch is a module's COMPLETE statement about what it
// declares. `fakeApi` throws on a second call, so registering twice fails here.
assert.ok(api.record.eventActions.length > 0)
})
test('an action may only spend a budget dimension some module declared', () => {
const { api } = register()
// Core refuses a `cost()` naming an undeclared dimension at save, at the dry
// run and at dispatch, because the fix is a module's declaration rather than a
// deployment's cap. This module declares everything it spends, so the check is
// local; a module spending another module's dimension would have to loosen it.
const declared = new Set(api.record.eventBudgets.map((b) => b.id))
for (const action of api.record.eventActions) {
const sample = Object.fromEntries(action.params.map((p) => [p.name, p.example]))
for (const dimension of Object.keys(action.cost(sample))) {
assert.ok(declared.has(dimension), `${action.id} spends undeclared ${dimension}`)
}
}
})
test('a game restart asks core to reconcile, and a first sighting does not', () => {
const boot = require('../boot')
const sidecar = require('../sidecarClient')
const ctx = fakeCtx()
require('../core')._reset()
require('../core').init(ctx)
// First observation is not a restart. Treating it as one would sweep every
// ledgered resource on every website deploy, for no news.
boot.checkForRestart()
assert.deepStrictEqual(ctx.events.reconcile.calls, [])
// Same boot id: still nothing.
boot.checkForRestart()
assert.deepStrictEqual(ctx.events.reconcile.calls, [])
// The game came back as something else. Core cannot see this and must be told.
sidecar.simulateRestart()
boot.checkForRestart()
assert.strictEqual(ctx.events.reconcile.calls.length, 1)
// And only once for one restart.
boot.checkForRestart()
assert.strictEqual(ctx.events.reconcile.calls.length, 1)
})

View File

@@ -0,0 +1,399 @@
// ── The four traps, as tests ──────────────────────────────────────────────
//
// `config/eventActions.js` marks four rules TRAP 1..4 and says all four are
// invisible until an outage. That is a bad property for a rule to have and a good
// reason to test it, because the alternative is finding out in production once.
//
// Each of the four gets a test that FAILS if the rule is broken — not one that
// asserts the current value. Trap 1 in particular is asserted as an inequality
// between two constants that live in different files, which is the only form that
// survives somebody tuning the client.
//
// Everything here runs without core, without a database and without a game: the
// declarations are plain objects and the client's transport is simulated. What it
// cannot prove is that core accepts these declarations — a fake that agreed with
// a mistake is exactly how a module ships green and refuses to load. That check
// is `checkCoreApi.js` plus a run against a real core, and the kit's
// `ci/core-ref.json` is where its date is written down.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
const core = require('../core')
core.init(fakeCtx())
/* eslint-disable global-require */
const events = require('../config/eventActions')
const sidecar = require('../sidecarClient')
const clanDb = require('../model/clans/clanProvider.db')
/* eslint-enable global-require */
// Stubbed at the `.db.js` seam, the same way `clanProvider.test.js` does it:
// there is no database here, and an action's `verify` reads one.
const CLANS = [{ externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 3 }]
clanDb.listClans = async () => CLANS
clanDb.findClan = async (externalId) => CLANS.find((c) => c.externalId === externalId)
const action = events.ACTIONS.find((a) => a.id === 'examplegame.beacon.light')
const lease = events.LEASES.find((l) => l.id === 'examplegame.rate.gather')
/** A fresh key per call, the way core's is a function of a step's identity. */
let keyCounter = 0
const nextKey = () => `test-key-${(keyCounter += 1)}`
// ══ Shape ═════════════════════════════════════════════════════════════════
test('every declaration is namespaced with the module id', () => {
const ids = [
...events.BUDGETS.map((b) => b.id),
...events.OPTION_SOURCES.map((s) => s.id),
...events.LEASES.map((l) => l.id),
...events.ACTIONS.map((a) => a.id),
]
for (const id of ids) {
assert.ok(id.startsWith('examplegame.'), `${id} is not namespaced — core refuses it`)
}
})
test('every param declares an example, optional ones included', () => {
for (const a of events.ACTIONS) {
for (const p of a.params) {
assert.ok(p.example !== undefined, `${a.id}.${p.name} has no example`)
}
}
})
test("an action's `source` names an option source this module registers", () => {
// Core resolves this across every module, so a source another module owns is
// legal. Checking the local case is still worth doing: a typo in your own id is
// the overwhelmingly likely mistake, and it degrades the field to free text in
// silence rather than failing.
const sources = new Set(events.OPTION_SOURCES.map((s) => s.id))
for (const a of events.ACTIONS) {
for (const p of a.params) {
if (p.source && p.source.startsWith('examplegame.')) {
assert.ok(sources.has(p.source), `${a.id}.${p.name} names an unregistered source`)
}
}
}
})
test("an action that ledgers declares `revert`", () => {
for (const a of events.ACTIONS) {
if (a.reversible === 'ledger') {
assert.strictEqual(typeof a.revert, 'function', `${a.id} ledgers but cannot undo`)
}
}
})
// ══ TRAP 1 — the failure default, and the budget that makes it reachable ══
test('TRAP 1: budgetMs strictly exceeds the client timeout', () => {
// The inequality, not the value. Core classifies a budget timeout as a retry
// WITHOUT asking the action, so if this ever inverts, every `retry: false`
// below becomes unreachable code and nothing else in this suite would notice —
// the action would still return it, and core would still retry.
for (const a of events.ACTIONS) {
assert.ok(
a.budgetMs > sidecar.TIMEOUT_MS,
`${a.id}: budgetMs ${a.budgetMs} must exceed the client's ${sidecar.TIMEOUT_MS}`,
)
}
})
test('TRAP 1: an unrecognised failure is a RETRY', () => {
// The default direction. A module that listed the transient statuses and
// defaulted the rest to terminal would stop retrying the moment its sidecar
// grew a status nobody here had heard of.
const verdict = events.classify({ ok: false, status: 'something-new' })
assert.strictEqual(verdict.ok, false)
assert.strictEqual(verdict.retry, true)
})
test('TRAP 1: a timeout is a retry and an unknown command is not', () => {
assert.strictEqual(events.classify({ ok: false, status: 'timeout' }).retry, true)
assert.strictEqual(events.classify({ ok: false, status: 'unknown-command' }).retry, false)
})
test('a refusal says WHY, in the field core actually reads', async () => {
// Core's dispatcher carries `error` off a failure envelope and nothing else.
// A reason under any other name — `detail`, `message`, `reason` — is dropped in
// silence and the operator sees "<action id> refused". This test exists because
// the first draft of this template used `detail`, on the strength of the one
// place `EVENTS.md` mentions it, and every refusal it produced was anonymous.
const answer = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'clan-1', count: 0 },
})
assert.strictEqual(answer.ok, false)
assert.strictEqual(typeof answer.error, 'string')
assert.ok(answer.error.length > 0, 'a refusal with no `error` tells an author nothing')
// And the same for a failure this module classified rather than authored.
assert.strictEqual(typeof events.classify({ ok: false, status: 'timeout' }).error, 'string')
})
test('TRAP 1: a refusal the second attempt would repeat says retry: false', async () => {
// The arm the inequality above exists to keep reachable. A count core would
// hand back identically on a retry is not worth a second round trip.
const answer = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'clan-1', count: 9999 },
})
assert.strictEqual(answer.ok, false)
assert.strictEqual(answer.retry, false)
})
// ══ TRAP 2 — the idempotency passthrough ═════════════════════════════════
test("TRAP 2: perform passes core's key through, unchanged", async () => {
const seen = []
const realSend = sidecar.send
// Wrapping the module's own client rather than a fake one: what is under test
// is that the key reaches the call, and a fake client would only prove the
// test passed it to itself.
sidecar.send = async (command, payload, options) => {
seen.push(options && options.idempotencyKey)
return realSend(command, payload, options)
}
try {
const key = nextKey()
await action.perform({ idempotencyKey: key, params: { clanId: 'clan-1', count: 2 } })
assert.deepStrictEqual(seen, [key], 'the key core gave us is not the key that went down the wire')
} finally {
sidecar.send = realSend
}
})
test('TRAP 2: a retry under the same key changes the world once', async () => {
// The property the passthrough buys, stated as behaviour rather than as a
// parameter. Two attempts, one key: the second collects the answer the first
// already produced, and the refs are identical.
const key = nextKey()
const params = { clanId: 'clan-1', count: 3 }
const first = await action.perform({ idempotencyKey: key, params })
const second = await action.perform({ idempotencyKey: key, params })
assert.strictEqual(first.ok, true)
assert.strictEqual(second.ok, true)
assert.deepStrictEqual(
second.resources.map((r) => r.ref),
first.resources.map((r) => r.ref),
'the repeat produced NEW refs — that is two sets of beacons and one ledger',
)
})
test('TRAP 2: a fresh key on the same params is a second, real change', async () => {
// The control for the test above. If this passed identically, the far end
// would be deduplicating on the params rather than on the key, and the test
// above would be proving nothing.
const params = { clanId: 'clan-1', count: 3 }
const first = await action.perform({ idempotencyKey: nextKey(), params })
const second = await action.perform({ idempotencyKey: nextKey(), params })
assert.notDeepStrictEqual(
second.resources.map((r) => r.ref),
first.resources.map((r) => r.ref),
)
})
test('TRAP 2: a call with no key is refused rather than sent', async () => {
const answer = await sidecar.send('beacon.light', { clanId: 'clan-1', count: 1 }, {})
assert.strictEqual(answer.ok, false)
assert.strictEqual(answer.status, 'no-idempotency-key')
})
// ══ TRAP 3 — core records a resource BEFORE it is confirmed ══════════════
test('TRAP 3: reverting something that was never made is a SUCCESS', async () => {
const answer = await action.revert({
idempotencyKey: nextKey(),
resources: [{ kind: 'beacon', ref: 'beacon:never-existed:1' }],
})
// The message avoids the words `from "..."` on purpose: `checkImports.js` is
// deliberately textual and reads that shape as an import specifier, prose or not.
assert.strictEqual(answer.ok, true, 'removing something absent must be a success')
})
test('TRAP 3: revert is idempotent — core may ask more than once', async () => {
const made = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'clan-1', count: 2 },
})
const first = await action.revert({ idempotencyKey: nextKey(), resources: made.resources })
const again = await action.revert({ idempotencyKey: nextKey(), resources: made.resources })
assert.strictEqual(first.ok, true)
assert.strictEqual(again.ok, true)
})
test('TRAP 3: revert is called with NO resources and only a key', async () => {
// The lost-answer case: core knows a dispatch went out under this key and never
// learned what it made. This module CAN answer it. One that cannot must say
// `{ ok: false }` and let a human see the row — never `{ ok: true }`, which is
// how a resource burns forever with the ledger reporting it cleaned up.
const answer = await action.revert({ idempotencyKey: nextKey(), resources: [] })
assert.strictEqual(answer.ok, true)
})
test('TRAP 3: reconcile reports what is gone and never guesses', async () => {
const made = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'clan-1', count: 2 },
})
const before = await action.reconcile({ resources: made.resources })
assert.strictEqual(before.ok, true)
assert.deepStrictEqual(before.inForce.sort(), made.resources.map((r) => r.ref).sort())
sidecar.simulateRestart()
const after = await action.reconcile({ resources: made.resources })
assert.strictEqual(after.ok, true)
assert.deepStrictEqual(after.inForce, [], 'a restart lost them; reconcile must say so')
})
// ══ TRAP 4 — the cost that is priced and never reconciled ════════════════
test('TRAP 4: cost counts what one invocation actually makes', async () => {
// The failure this catches is `() => ({ 'examplegame.beacons': 1 })`, which
// would pass every other test in this file and turn an operator's cap of 30
// into a cap of 750. Core prices `cost` before dispatch and NEVER reconciles it
// against the resources that come back, so nothing else can catch it.
const params = { clanId: 'clan-1', count: 7 }
const priced = action.cost(params)
const made = await action.perform({ idempotencyKey: nextKey(), params })
assert.strictEqual(
priced['examplegame.beacons'],
made.resources.length,
'the action declared a different number than it made — every cap on this dimension is a lie',
)
})
test('TRAP 4: cost only names dimensions this module declared', () => {
// A `cost()` naming an undeclared dimension is REFUSED at save, at the dry run
// and at dispatch, because the fix is a module's declaration rather than a
// deployment's cap. Cheaper to find here.
const declared = new Set(events.BUDGETS.map((b) => b.id))
for (const a of events.ACTIONS) {
const sample = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
for (const dimension of Object.keys(a.cost(sample))) {
assert.ok(declared.has(dimension), `${a.id} spends ${dimension}, which no module here declares`)
}
}
})
// ══ verify ════════════════════════════════════════════════════════════════
test('verify changes nothing', async () => {
const before = await action.reconcile({ resources: [] })
const dry = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'clan-1', count: 5 },
verify: true,
})
assert.strictEqual(dry.ok, true)
assert.strictEqual(dry.resources, undefined, 'a dry run must not report resources it did not make')
// Nothing was lit, so nothing new is in force. The assertion is weak on its own
// and strong beside the TRAP 3 reconcile test above, which proves the same call
// does see what `perform` makes.
const after = await action.reconcile({ resources: [] })
assert.deepStrictEqual(after.inForce, before.inForce)
})
test('verify answers honestly rather than always true', async () => {
const dry = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'no-such-clan', count: 1 },
verify: true,
})
assert.strictEqual(dry.ok, false)
assert.strictEqual(dry.retry, false)
})
// ══ The lease ═════════════════════════════════════════════════════════════
test('a lease reads a baseline, holds a value, and gives it back', async () => {
sidecar.simulateRestart()
const baseline = await lease.read()
assert.strictEqual(baseline.ok, true)
assert.strictEqual(baseline.value, 1.0)
const until = new Date(Date.now() + 60_000)
assert.strictEqual((await lease.apply(2.5, until)).ok, true)
assert.strictEqual((await lease.read()).value, 2.5)
const back = await lease.restore(baseline.value, { expected: 2.5 })
assert.strictEqual(back.ok, true)
assert.strictEqual((await lease.read()).value, 1.0)
})
test('a lease reports DRIFT rather than overwriting what somebody changed', async () => {
sidecar.simulateRestart()
const baseline = await lease.read()
await lease.apply(3, new Date(Date.now() + 60_000))
// Somebody moved it by hand, mid-event.
await lease.apply(4, new Date(Date.now() + 60_000))
const back = await lease.restore(baseline.value, { expected: 3 })
assert.strictEqual(back.ok, true)
assert.strictEqual(back.drifted, true, 'restoring over a hand-edit silently is the bug')
assert.strictEqual(Number(back.value), 4)
})
test('inForce is a different question from read', async () => {
sidecar.simulateRestart()
// Nothing held: the live value is the default.
assert.strictEqual((await lease.inForce()).held, false)
await lease.apply(2, new Date(Date.now() + 60_000))
assert.strictEqual((await lease.inForce()).held, true)
// A restart takes the hold with it, and `inForce` is the only thing that says
// so — `read()` would answer 1.0, which is also what an un-held lease reads.
sidecar.simulateRestart()
assert.strictEqual((await lease.inForce()).held, false)
})
test('the lease declares a duration bound core can enforce', () => {
for (const l of events.LEASES) {
assert.ok(l.maxDurationMs > 0, `${l.id} has no duration bound`)
assert.strictEqual(typeof l.read, 'function')
assert.strictEqual(typeof l.apply, 'function')
assert.strictEqual(typeof l.restore, 'function')
}
})
// ══ The option source ═════════════════════════════════════════════════════
test('an option source answers from live data', async () => {
const source = events.OPTION_SOURCES.find((s) => s.id === 'examplegame.options.clans')
const options = await source.resolve()
assert.ok(Array.isArray(options))
assert.strictEqual(options.length, CLANS.length)
for (const option of options) {
assert.strictEqual(typeof option.value, 'string')
assert.strictEqual(typeof option.label, 'string')
}
})
test('an option source that fails degrades rather than raising', async () => {
// Core turns a refusal into a free-text field with a warning; it never blocks
// the authoring form. A resolver that threw would be a screen this module's
// outage takes away, for a field whose value the operator very often knows.
const real = clanDb.listClans
clanDb.listClans = async () => { throw new Error('database is down') }
try {
const source = events.OPTION_SOURCES.find((s) => s.id === 'examplegame.options.clans')
assert.deepStrictEqual(await source.resolve(), [])
} finally {
clanDb.listClans = real
}
})