Compare commits

...

4 Commits

Author SHA1 Message Date
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
16 changed files with 1839 additions and 33 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. the contract cannot do yet.
2. **`template/`** — a module that builds and loads, doing almost nothing. Copy it, 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. 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 ## 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_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. | | [`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. | | [`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 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 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 [module-uo]: https://gitea.whitlocktech.com/RunicGateway/Module-uo
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md [api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md [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 [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 [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 [linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md

View File

@@ -89,6 +89,52 @@ 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 put what arrives while the website is not running, because the website not running
is exactly the case. 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.
## 3. The wire is a versioned contract, not a build dependency ## 3. The wire is a versioned contract, not a build dependency
Your sidecar and your module ship separately, on different schedules, to hosts you Your sidecar and your module ship separately, on different schedules, to hosts you

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 anything it throws, because the alternative is an exception unwinding somewhere in
the engine's main loop. 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 ## Reconnect, and what to send on connect
Your sidecar restarts independently of your game. It comes back with an empty 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 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. 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 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 game connection and the durable copy of what it said, and a plugin that feeds the
sidecar without ever waiting on it. 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 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 valuable thing this repo can receive — [tell us][issues], and please say where you
left the kit and what you did next. 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 # 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 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. 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. | | 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. | | 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. | | 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 Chapters 1, 2 and 5 quote `template/`, which CI builds against a pinned core, so
code is a tree that is proved rather than prose that looks like one. Chapters 3 and their code is a tree that is proved rather than prose that looks like one. Chapters
4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by line, on 3 and 4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by
purpose: those repositories move for their own reasons and a line number in a book line, on purpose: those repositories move for their own reasons and a line number in
is wrong the moment they do. 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 ## 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_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. | | [`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. | | [`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 The chapters teach: the order to do things in, the reasoning, and the mistakes that
cost this project time. cost this project time.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md [api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md [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 [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 [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 [linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md

View File

@@ -1,14 +1,16 @@
{ {
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"branch": "main", "branch": "main",
"ref": "66bb3b9a3fad01112c06f32d931c9bae56d22de6", "ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
"why": [ "why": [
"The core this kit is written against, pinned to a commit rather than a branch.", "The core this kit is written against, pinned to a commit rather than a branch.",
"This one is the engagement cutover, the commit MODULE_API_VERSION 1.9.0 reached", "This one is the EVENT SYSTEM cutover, the commit MODULE_API_VERSION 1.10.0",
"`main` on, and 1.9.0 is what template/module.json declares. It moved here from", "reached `main` on (website#199), and 1.10.0 is what template/module.json",
"1.6.0 (the Teams cutover) because engagement expanded the contract the book", "declares. It moved here from 66bb3b9a (1.9.0, the engagement cutover) because",
"teaches by three registrations and two calls: a module now declares what its", "the event contract expanded the book by a whole chapter: a module now declares",
"game can announce and never who is told.", "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", "Moving this pin is the moment someone re-reads the chapters: CI asserts the",
"version template/module.json declares still equals this core's", "version template/module.json declares still equals this core's",
@@ -19,29 +21,40 @@
"own. Nothing goes red until someone moves the pin. Between cutovers the kit is", "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.", "not wrong, it is DATED - and this file is where the date is written down.",
"", "",
"The mechanism earned its keep again here. Writing chapter 2's engagement", "This pin move is a REPAIR as well as a date. Chapter 5 landed (#10) declaring",
"section against 1.9.0 found that the seeded body a module ships is the one", "^1.10.0 while this file still named a 1.9.0 core, so `main` has been red on",
"thing registerEngagementSeeds does not validate - it checks that `blocks` is a", "checkCoreApi since it merged -- deliberately, and stated in that PR, but the",
"non-empty array and stops - so the template's own example body had a heading", "red belongs to the window and not to the repo. This is the commit that was",
"level of 2 where the block registry takes 'h2', and no block ids at all. It", "always going to close it, and it could not be written until the events sha",
"would have registered, seeded, and failed the first time an operator opened it.", "existed on `main`. Same shape Teams phase 11 used.",
"Caught by running the template's register() through core's real registry at",
"this ref, which is what a re-read is for; both the fix and the gap are now in",
"the chapter and beside the code.",
"", "",
"That gap is also why this file's own instruction is not enough on its own. The", "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", "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", "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,", "and core refuses would ship green, so a pin move is a run against a real core,",
"not just an edit here.", "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", "The branch said `edge` until 2026-08-12, when the module system cut over and",
"that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an", "that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an",
"`edge` of their own and this pin skipped both: the kit is written against what", "`edge` of their own and this pin skipped both; the Event System cut a third,",
"shipped, never against what is in flight. Nothing in CI reads the branch field", "and this pin skipped that too until it reached `main`. The kit is written",
"- it clones the repo and checks out the sha - which is why a wrong label here", "against what shipped, never against what is in flight. Nothing in CI reads the",
"would sit unnoticed. It is for the person deciding whether a newer core is", "branch field - it clones the repo and checks out the sha - which is why a wrong",
"worth re-reading the book for.", "label here would sit unnoticed. It is for the person deciding whether a newer",
"core is worth re-reading the book for.",
"", "",
"Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one", "Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
"sha, reviewable in a diff." "sha, reviewable in a diff."

View File

@@ -15,6 +15,9 @@ Installed into a core, it adds:
for the deployment — the one registration where core calls YOU and waits; 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 - **three inverted extension slots**, declared by this module on the clan page for
core to fill; 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. - **both lifecycle hooks**, so there is something to see at boot and at shutdown.
That is deliberately less than your module will do. What it is *complete* about is That is deliberately less than your module will do. What it is *complete* about is
@@ -28,7 +31,9 @@ module.json what core reads first — id, version, coreApi, moun
server/ server/
index.js register(ctx, api) — the entire server-side handshake index.js register(ctx, api) — the entire server-side handshake
core.js the lazy accessors over ctx; read this second 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/schema.sql idempotent, replayed every boot
db/purge.sql destructive, run only by an explicit admin purge db/purge.sql destructive, run only by an explicit admin purge
model/worldStatus/ the .db.js / .model.js pair model/worldStatus/ the .db.js / .model.js pair
@@ -38,6 +43,7 @@ server/
scripts/checkImports.js the module boundary, enforced scripts/checkImports.js the module boundary, enforced
scripts/swaggerFragment.js generates swagger-fragment.json from your own routes scripts/swaggerFragment.js generates swagger-fragment.json from your own routes
test/ the suites — start with entry.test.js test/ the suites — start with entry.test.js
test/eventActions.test.js the four traps chapter 5 is about, each as a failing test
client/ client/
vite.config.js the library build: anchored aliases, external: [] vite.config.js the library build: anchored aliases, external: []
src/entry.jsx registers routes, nav and declared slots at evaluation time src/entry.jsx registers routes, nav and declared slots at evaluation time
@@ -113,6 +119,7 @@ backticking table names**.
| `server/package.json` | package `name` and `description` | | `server/package.json` | package `name` and `description` |
| `server/core.js` | the message every accessor throws | | `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/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/boot.js` | the placeholder world name |
| `server/db/schema.sql` | every table name — the prefix must be your id | | `server/db/schema.sql` | every table name — the prefix must be your id |
| `server/db/purge.sql` | the same table names | | `server/db/purge.sql` | the same table names |
@@ -125,6 +132,7 @@ backticking table names**.
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` | | `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
| `server/test/_fakes.js` | `ctx.moduleId` | | `server/test/_fakes.js` | `ctx.moduleId` |
| `server/test/entry.test.js` | the trigger id the world-status test asserts | | `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/worldStatus.test.js` | the fixture's world name |
| `server/test/clanProvider.test.js` | the fixture's world name | | `server/test/clanProvider.test.js` | the fixture's world name |
| `server/package-lock.json` | **regenerated**`npm install --prefix server` | | `server/package-lock.json` | **regenerated**`npm install --prefix server` |
@@ -147,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 nothing left to rename. A checklist nobody verifies is a checklist that is wrong
by the second edit. 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 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`, 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 `/settings`, `/version`, `/contact` and `/teams` are already taken, which is why

View File

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

View File

@@ -31,6 +31,7 @@ const core = require('./core')
const worldStatusDb = require('./model/worldStatus/worldStatus.db') const worldStatusDb = require('./model/worldStatus/worldStatus.db')
const clanDb = require('./model/clans/clanProvider.db') const clanDb = require('./model/clans/clanProvider.db')
const sidecar = require('./sidecarClient')
const log = core.logger('boot') 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. // so that there is something for the shutdown hook to actually do.
let refreshTimer = null 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 const REFRESH_MS = 30 * 1000
/** /**
@@ -70,6 +75,9 @@ async function refresh() {
const previous = await worldStatusDb.getStatus() const previous = await worldStatusDb.getStatus()
await worldStatusDb.setStatus(next) 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. // `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 // Treating it as one would announce the world coming online to everyone the
// first time an operator started the site. // first time an operator started the site.
@@ -96,6 +104,42 @@ async function refresh() {
} }
} }
/**
* 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. * Two clans, so that the Team provider has something to be authoritative about.
* *
@@ -162,7 +206,8 @@ async function onBoot() {
async function onShutdown() { async function onShutdown() {
if (refreshTimer) clearInterval(refreshTimer) if (refreshTimer) clearInterval(refreshTimer)
refreshTimer = null refreshTimer = null
lastBootId = null
log.info('shut down') 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

@@ -106,6 +106,23 @@ module.exports = {
// operator's log six weeks later. // operator's log six weeks later.
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope), 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 // 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 // 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. // 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 worldRouter = require('./router/public/world.router')
const clansRouter = require('./router/public/clans.router') const clansRouter = require('./router/public/clans.router')
const clanProvider = require('./model/clans/clanProvider.model') const clanProvider = require('./model/clans/clanProvider.model')
const eventActions = require('./config/eventActions')
const boot = require('./boot') const boot = require('./boot')
/* eslint-enable global-require */ /* eslint-enable global-require */
@@ -262,6 +263,31 @@ module.exports = function register(ctx, api) {
], ],
}) })
// ── 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 // The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
// module's schema fragment, and BEFORE the HTTP listener binds — so a module // module's schema fragment, and BEFORE the HTTP listener binds — so a module
// that must not serve traffic until it has warmed a cache gets that for free. // that must not serve traffic until it has warmed a cache gets that for free.

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

@@ -53,7 +53,11 @@ function fakeCtx(overrides = {}) {
// whole of what a module may do with it: fire a declared event and stop. // 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 — // 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. // a fake that returned a receipt would invite a module to wait on one.
events: { emit: spy(undefined) }, // `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: { middleware: {
requireAuth: (req, res, next) => next(), requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(), requireRole: () => (req, res, next) => next(),
@@ -93,6 +97,7 @@ function fakeApi() {
const record = { const record = {
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null, routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
triggers: null, audiences: null, engagementSeeds: null, triggers: null, audiences: null, engagementSeeds: null,
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
} }
const called = new Set() const called = new Set()
const once = (name) => { const once = (name) => {
@@ -114,6 +119,13 @@ function fakeApi() {
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers }, registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences }, registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds }, 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 }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
} }

View File

@@ -290,3 +290,64 @@ test('the world event fires on the transition and not on the poll', async () =>
await boot.refresh() await boot.refresh()
assert.deepStrictEqual(fresh.events.emit.calls, []) 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
}
})