Compare commits
8 Commits
39736f8448
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e852e5574d | |||
| ad37cade6e | |||
| 7f746aee3d | |||
| 5dc14fa626 | |||
| a72b002f75 | |||
| f89044b42e | |||
| e9ca759227 | |||
| a8fa524263 |
@@ -57,7 +57,10 @@ typing commands can only be polled, and polling turns "someone left at 14:02" in
|
||||
the contract cannot do yet.
|
||||
2. **`template/`** — a module that builds and loads, doing almost nothing. Copy it,
|
||||
rename it, and you have a running module before you have read a chapter.
|
||||
3. **The book** — [`book/`](book/), four chapters, in the order the work happens.
|
||||
3. **The book** — [`book/`](book/), five chapters, in the order the work happens.
|
||||
The first four are the job. The fifth is optional and comes after you have a
|
||||
working module: what to declare if you want a scheduled event on the website to
|
||||
be able to change your live world, and get it back afterwards.
|
||||
|
||||
## The one rule this kit follows
|
||||
|
||||
@@ -69,6 +72,7 @@ kit and one of them disagree, they win and the kit has a bug:
|
||||
| [`MODULE_API.md`][api] | Everything a module may do: `module.json`, `ctx`, the `register*` calls, the client registry, the UI kit, schema-fragment rules, the loader's obligations. |
|
||||
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
||||
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The shard↔sidecar wire protocol, as one real sidecar implements it. |
|
||||
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, what core owns, and every rule chapter 5 explains the reasoning behind. |
|
||||
|
||||
The kit *teaches*: the order to do things in, the reasoning, worked examples, and
|
||||
the mistakes that cost this project time. Where it must show a member list it
|
||||
@@ -104,6 +108,7 @@ same licence, and so does anything derived from it.
|
||||
[module-uo]: https://gitea.whitlocktech.com/RunicGateway/Module-uo
|
||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
||||
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
|
||||
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||
|
||||
@@ -147,8 +147,9 @@ statement of your dependencies, and it makes a test double for it — see
|
||||
|
||||
## What you register
|
||||
|
||||
Eight calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
|
||||
is not their signatures but the model behind them.
|
||||
Twelve calls — ten registrations and the two lifecycle hooks — all synchronous,
|
||||
all listed in [§2.4][api]. What is worth knowing is not their signatures but the
|
||||
model behind them.
|
||||
|
||||
**Every call stages; nothing is committed until your whole module is known good.**
|
||||
The shape of a claim is checked at the call, so a malformed one throws with your
|
||||
@@ -279,6 +280,119 @@ Every hook is awaited and none may throw past core: a subscriber's failure costs
|
||||
neither another subscriber nor the save itself. A hiccup in your sidecar breaking
|
||||
somebody's blog post edit would be a worse bug than a stale mirror.
|
||||
|
||||
### Telling core something happened
|
||||
|
||||
Three registrations and one call, and together they are the seam where a module
|
||||
is most tempted to reach past the boundary. The rule that keeps them safe is one
|
||||
sentence: **you declare what CAN happen; core decides who is told.**
|
||||
|
||||
A **trigger** is not a notification stream, and the two are easy to confuse
|
||||
because both are catalogs of things that happen in your game. A stream is a
|
||||
subscribe toggle, and you publish to it yourself. A trigger is a **payload
|
||||
contract**: it names the variables an event carries and how wide an audience it
|
||||
may ever be given, an operator writes rules against it, and *core* does the
|
||||
sending. Their ids share one namespace, so declaring both for the same id is
|
||||
legal — that is one event with a toggle and a contract — while taking an id
|
||||
another module owns is not.
|
||||
|
||||
Two fields on a trigger are worth more than their size.
|
||||
|
||||
**`ceiling` is required and has no default, and the values are ordered by
|
||||
containment rather than by size.** It is the widest audience a rule on this
|
||||
trigger may ever be given. There is no safe value to guess: `owner` silently
|
||||
breaks a broadcast, `authenticated` silently widens something meant for staff.
|
||||
And the ladder reading of the seven values is the trap — a `staff` ceiling does
|
||||
**not** permit `owner`, because "one person" for a cheat-detection event is *the
|
||||
player it was detected on*. Fewer people is not less exposure.
|
||||
|
||||
**`subjectKey` must name one of your declared variables**, because it is what the
|
||||
cooldown is keyed on — "once per house", not "once per user". Core checks it at
|
||||
registration and refuses the module, so this is one you meet at your first boot
|
||||
rather than in production. The check is there because the failure it prevents is
|
||||
the silent kind: a subjectKey naming nothing keys every subject on `undefined`,
|
||||
which looks exactly like the feature working right up until two houses share it.
|
||||
|
||||
Every variable needs an `example`, and it is not decoration: it is what lets an
|
||||
operator preview and test-send a body without waiting for a real game event,
|
||||
which is the reason template systems ship untested. The type set is closed and
|
||||
has no `object` or `array` — a message that has to walk a structure has outgrown
|
||||
interpolation.
|
||||
|
||||
An **audience** is a named set of *people* you can resolve over your own data,
|
||||
for an operator to point a rule at. "This clan's members" is one. "Everyone who
|
||||
opened the last mail" is not, and nothing here builds it.
|
||||
|
||||
**Your resolver returns user ids and nothing else.** It is not handed a template,
|
||||
a channel or an address, and it cannot enumerate them; core maps ids to addresses
|
||||
on its own side, after preferences, suppression and the verification gate. That
|
||||
narrowness is deliberate — a module still cannot send mail, and this is the
|
||||
obvious place a back door would go. Two consequences follow from it:
|
||||
|
||||
- **A resolver that fails resolves to NOBODY**, never to everybody and never to
|
||||
its last good answer. Core enforces that, and your resolver should choose it
|
||||
too, so the log can say which clan.
|
||||
- **Its params are CONSTANT.** An operator fills them in when they save the rule.
|
||||
There is no way to say "the clan this event was about" — if a rule needs that,
|
||||
the *event* carries its own recipients instead. This is the constraint most
|
||||
worth knowing before you design around it rather than after.
|
||||
|
||||
The third registration ships the **content**: the bodies your messages use and
|
||||
the rules that decide when one is sent. Both arrive **switched off**, and
|
||||
`enabled` is not a parameter. An operator turns a module's mail on; installing a
|
||||
module never does.
|
||||
|
||||
The two halves have different lifetimes, and the asymmetry is the contract.
|
||||
**Templates re-ensure on every boot** under a seed version, so a better default
|
||||
reaches deployments that never edited it while one an operator *has* edited is
|
||||
left alone. **Rule groups are offered once, per named group key**, because
|
||||
re-offering would resurrect a rule somebody deleted and reset one they enabled.
|
||||
The consequence is easy to trip over: a rule appended to an existing group
|
||||
reaches **fresh installs only**. That is the guarantee rather than a limitation
|
||||
to route around, and a rule that must reach existing deployments takes a new
|
||||
group key. You name the groups, so the choice is yours to make knowingly.
|
||||
|
||||
Core's generic bodies are a first-class answer rather than a fallback. Point a
|
||||
channel at `notify.event` or `inapp.event` and author nothing; ship a body of your
|
||||
own when the message has something to say that a structural projection of the
|
||||
payload cannot.
|
||||
|
||||
**One thing in a seeded body is not checked when you register it.** The call
|
||||
asserts that `blocks` is a non-empty array and stops there; the body itself is
|
||||
validated by the block registry, which runs in the editor and in the renderer. So
|
||||
a malformed block registers cleanly, seeds cleanly, and first shows itself when an
|
||||
operator opens the body or a rule fires. Build one, open it in Admin → Engagement →
|
||||
Templates once, and you have checked the half that boot cannot.
|
||||
|
||||
Finally the call. `ctx.events.emit(triggerId, envelope)` fires one of your own
|
||||
triggers — core binds the owner from the calling module and never reads it from
|
||||
the arguments, so there is no shape of this call that fires somebody else's
|
||||
event. It returns nothing and, in production, never throws: there is nothing a
|
||||
module could correctly do about a delivery failure from inside a game-event
|
||||
handler, so there is nothing to await. **Outside production it does throw**, at
|
||||
your call site — a payload that does not match the contract you declared is a bug
|
||||
rather than a condition, and the throw is how you meet it in your own tests
|
||||
instead of in an operator's log six weeks later.
|
||||
|
||||
Beside it is the one call that skips the rules entirely.
|
||||
`ctx.inbox.push(userId, item)` writes a single item into a single person's on-site
|
||||
inbox. Reach for it when there is nothing for an operator to decide — a job that
|
||||
person started has finished — and for anything else use a trigger, so the message
|
||||
can be turned off, re-targeted, or sent by mail as well without a code change. The
|
||||
posture is `emit`'s: the owner is bound from the calling module, it returns
|
||||
nothing, and it will not tell you that the user has that channel switched off,
|
||||
because a module that could see that could enumerate people's preferences one
|
||||
write at a time.
|
||||
|
||||
**Emit on the transition, not on the poll.** The template's `refresh()` runs every
|
||||
thirty seconds and emits only when the world's online state actually changed.
|
||||
Core's cooldown and hourly cap would both hold if it did not — but leaning on
|
||||
them means emitting "the world is still up" and calling it news, and the operator
|
||||
who tightens the cooldown to stop it has hidden your bug rather than fixed it.
|
||||
Two smaller traps sit inside the same function and are worth reading in
|
||||
`template/server/boot.js`: the previous state has to be read *before* the write,
|
||||
or every poll looks like no change at all, and the very first boot has no previous
|
||||
state, which is not a change either.
|
||||
|
||||
### Becoming the source of Teams
|
||||
|
||||
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and
|
||||
|
||||
@@ -89,6 +89,93 @@ A module cannot do any of this from inside the website process. There is nowhere
|
||||
put what arrives while the website is not running, because the website not running
|
||||
is exactly the case.
|
||||
|
||||
## 2a. The other direction, if you ever want events
|
||||
|
||||
Everything above is about data leaving the game. Skip this section until you want
|
||||
[chapter 5](05-events.md) — but read it *before* you build the sidecar rather than
|
||||
after, because retrofitting it is more work than allowing for it.
|
||||
|
||||
An event on the website is core telling your module *"do this to the world now"*,
|
||||
and your module telling your sidecar, and your sidecar telling the game. That is a
|
||||
**command** — a request with a reply, going the way nothing above goes. It needs
|
||||
three things the read path does not.
|
||||
|
||||
**Request/reply correlation.** A command is not a broadcast: the caller waits for
|
||||
an answer and has to know which answer is theirs. `uo-link` does this in
|
||||
`sidecar/src/rpc.rs` — an id on the way out, a map of pending calls, the reply
|
||||
matched back and the waiter woken. You need it for reads that ask the game a live
|
||||
question too, so it is often already there; commands are what make it load-bearing.
|
||||
|
||||
**An idempotency key, executed at most once, stored where the game is.** Core hands
|
||||
your module a key that is a function of the step's identity and never of the
|
||||
attempt, so every retry carries the same one. The far end must execute a given key
|
||||
once and answer a repeat with **the reply the first attempt produced** — not by
|
||||
running the command again.
|
||||
|
||||
That store belongs as close to the game as the state it protects. A store in the
|
||||
sidecar is right for a command whose effect is the sidecar's own; a command that
|
||||
changes the *world* needs the store where the world is, because the case it exists
|
||||
for is the game restarting mid-run. And a repeat arriving while the original is
|
||||
still in flight is its own answer — "busy", transient by construction, because the
|
||||
work is happening.
|
||||
|
||||
Without this, a command that arrived, ran, and whose acknowledgement was lost is
|
||||
indistinguishable from one that never arrived. The only safe policy is then never
|
||||
to retry, which means a game restarting mid-event writes the step off.
|
||||
|
||||
**A deadline the game enforces on its own.** A borrowed value — a doubled gather
|
||||
rate, a raised spawn cap — carries an expiry down the wire, and the game side must
|
||||
restore the baseline when it passes **without being asked again**. The website's
|
||||
copy of that deadline is for the console. The game's copy is the fail-safe: if the
|
||||
website is never heard from again, the value still comes back.
|
||||
|
||||
Two details that are easy to get wrong and expensive to change later. Send the
|
||||
deadline as a **duration**, not an absolute time — two machines' clocks are two
|
||||
clocks. And if the borrowed value lives in the game's own save file, the *hold*
|
||||
must be persisted and the timer re-armed at load; a restart preserves the change
|
||||
and destroys only the thing that would have undone it.
|
||||
|
||||
## 2b. The game host already has the files your site wants
|
||||
|
||||
There is a third kind of traffic, and it is worth knowing about before you decide
|
||||
your sidecar only ever forwards live state. Most games keep **content on the host**
|
||||
that a website wants to show: sprites, icons, portraits, localisation tables, map
|
||||
or spawn definitions. It is static, it is large, and it changes only when an
|
||||
operator patches the game.
|
||||
|
||||
The tempting answer is to make the operator's problem: export it on a desktop with
|
||||
some third-party tool, upload the result, repeat after every patch. It works once
|
||||
and rots immediately, because nothing reminds anyone to redo it.
|
||||
|
||||
The better answer costs less than it sounds like: **the game host already has those
|
||||
files, and you already have a channel to the game host.** Route them over it.
|
||||
|
||||
Four design notes, all learned the expensive way in `uo-link`'s protocol 8 (the
|
||||
"asset bridge", [`v8.md`][v8]):
|
||||
|
||||
- **This is request/reply, never events.** A sidecar that persists and broadcasts
|
||||
every event would write megabytes of sprite into its own store and fan it out to
|
||||
every connected client. Content must ride the same correlated round-trip a query
|
||||
uses — see [chapter 5](05-events.md) for the shape.
|
||||
- **Serve one at a time, and say so in the protocol.** Decoding assets costs the
|
||||
game host real memory. One in-flight request with an explicit "busy" answer is
|
||||
simpler and safer than a queue, and a caller that treats busy as flow control
|
||||
rather than failure gets a working import out of it.
|
||||
- **Two stages: what exists, then what changed.** A cheap call that returns a list
|
||||
with a hash per item and no content, then a second that fetches only the hashes
|
||||
that moved. The common case — a restart that changed nothing — must cost one
|
||||
small round trip, not a re-download of everything.
|
||||
- **Version your *derivation*, separately from the protocol.** If you improve how
|
||||
you read a file, the bytes you produce change while the source file's hash does
|
||||
not. `uo-link` carries an `EXTRACTOR_VERSION` for exactly that, and a consumer
|
||||
treats a change in it like a changed hash.
|
||||
|
||||
And one operational note, because it is the part that surprises people: **do not
|
||||
import on boot.** A patch is an event the operator knows about and your website does
|
||||
not. Re-reading hundreds of megabytes on every restart to discover that nothing
|
||||
changed pays for the rare case forever; a button an operator presses after they
|
||||
patch costs nothing and is honest about who knows what.
|
||||
|
||||
## 3. The wire is a versioned contract, not a build dependency
|
||||
|
||||
Your sidecar and your module ship separately, on different schedules, to hosts you
|
||||
@@ -200,4 +287,5 @@ it wrong takes the game down rather than the website.
|
||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||
[v8]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md
|
||||
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||
|
||||
@@ -103,6 +103,53 @@ escape into a game code path. `BridgeLink` wraps the inbound handler and logs
|
||||
anything it throws, because the alternative is an exception unwinding somewhere in
|
||||
the engine's main loop.
|
||||
|
||||
## A command that changes the world runs at most once
|
||||
|
||||
Skip this until you want [chapter 5](05-events.md). Everything above assumes an
|
||||
inbound line either asks a question or is a one-off an operator typed. An **event**
|
||||
is neither: it is unattended, it is retried, and what it does is permanent.
|
||||
|
||||
Three obligations, and they all live on this side of the wire because this is the
|
||||
side that has the world.
|
||||
|
||||
**Keep a key store, and persist it.** Every command an event sends carries an
|
||||
idempotency key — a function of the step's identity, never of the attempt, so a
|
||||
retry carries the one the first attempt did. Before executing, look the key up:
|
||||
|
||||
- **not seen** — execute, then record the key *with the reply you are about to
|
||||
send*;
|
||||
- **seen and finished** — send that stored reply back, unchanged. Do not re-run;
|
||||
- **seen and still running** — answer "busy". It is transient by construction, and
|
||||
the caller will retry; running it concurrently with itself is the failure.
|
||||
|
||||
The stored reply matters as much as the guard. A repeat that re-ran and returned a
|
||||
*new* serial would be two things in the world and one in the website's ledger,
|
||||
which is the exact failure the key exists to prevent, arrived at by a longer route.
|
||||
|
||||
**Persist it in the world save, not in memory**, if what the command creates
|
||||
survives a restart. The case this whole mechanism exists for is a game restarting
|
||||
mid-event, and a key store that dies with the process is a store that is empty in
|
||||
precisely that case.
|
||||
|
||||
**Own what an event made, and expire what it borrowed.**
|
||||
|
||||
An event-created thing has to be findable again later, because the website will ask
|
||||
you to remove it after the event and may ask more than once. That means a registry
|
||||
— a persisted map from the website's reference to the object — and it means
|
||||
`remove` is idempotent: **removing something that is not there is a success.** The
|
||||
website records a resource *before* it is confirmed, so it will ask you about
|
||||
things that may never have existed, and neither end can tell the difference.
|
||||
|
||||
A borrowed value is the mirror. It arrives with a duration, and you arm a timer that
|
||||
puts the baseline back when it expires. If the borrowed value lives in the save
|
||||
file, persist the hold and **re-arm the timer at load** — a restart preserves the
|
||||
change and destroys only the thing that would have undone it. Restore with a
|
||||
compare-and-set against what you applied: if a staff member has moved it by hand
|
||||
since, report that rather than overwriting them.
|
||||
|
||||
The through-line: **the game enforces the expiry, not the website.** If the website
|
||||
is never heard from again, every borrowed value still comes back on its own.
|
||||
|
||||
## Reconnect, and what to send on connect
|
||||
|
||||
Your sidecar restarts independently of your game. It comes back with an empty
|
||||
@@ -173,12 +220,25 @@ Two practical notes from that file, both general:
|
||||
If all eight hold, the worst a broken sidecar can do to your game is nothing at
|
||||
all — which is the entire point of the arrangement.
|
||||
|
||||
Three more, and only if you took commands (chapter 5):
|
||||
|
||||
9. A command's idempotency key is looked up before it is executed, and a repeat is
|
||||
answered with the stored reply rather than re-run.
|
||||
10. What an event made is in a persisted registry, and removing something absent is
|
||||
a success.
|
||||
11. A borrowed value's expiry is armed by this side, re-armed at load, and restored
|
||||
with a compare-and-set.
|
||||
|
||||
---
|
||||
|
||||
That is the book. The three parts are a module core loads, a sidecar that owns the
|
||||
game connection and the durable copy of what it said, and a plugin that feeds the
|
||||
sidecar without ever waiting on it.
|
||||
|
||||
[Chapter 5](05-events.md) is the optional fifth part: what to declare if you want
|
||||
the website to be able to change your world on a schedule, and the four mistakes
|
||||
that make that unsafe.
|
||||
|
||||
If you got this far and built something, the places you got stuck are the most
|
||||
valuable thing this repo can receive — [tell us][issues], and please say where you
|
||||
left the kit and what you did next.
|
||||
|
||||
364
book/05-events.md
Normal file
364
book/05-events.md
Normal 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
|
||||
@@ -1,6 +1,7 @@
|
||||
# The book
|
||||
|
||||
Four chapters, in the order the work happens.
|
||||
Five chapters, in the order the work happens. The first four are the job; the
|
||||
fifth is optional and comes after you have one.
|
||||
|
||||
Read [the dry run][dryrun] before any of them — a complete module designed on
|
||||
paper for a second game, and the shortest honest picture of the whole job.
|
||||
@@ -11,12 +12,20 @@ paper for a second game, and the shortest honest picture of the whole job.
|
||||
| 2 | [The website module](02-website-module.md) | The bulk of the work: `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do. |
|
||||
| 3 | [The sidecar](03-sidecar.md) | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is. |
|
||||
| 4 | [The game-side plugin](04-game-plugin.md) | The least code and the highest stakes: never block the game thread. |
|
||||
| 5 | [Making your module event-capable](05-events.md) | Optional, and the first thing here that can do damage: letting a scheduled event on the website change your live world, and get it back. |
|
||||
|
||||
Chapters 1 and 2 quote `template/`, which CI builds against a pinned core, so their
|
||||
code is a tree that is proved rather than prose that looks like one. Chapters 3 and
|
||||
4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by line, on
|
||||
purpose: those repositories move for their own reasons and a line number in a book
|
||||
is wrong the moment they do.
|
||||
Chapters 1, 2 and 5 quote `template/`, which CI builds against a pinned core, so
|
||||
their code is a tree that is proved rather than prose that looks like one. Chapters
|
||||
3 and 4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by
|
||||
line, on purpose: those repositories move for their own reasons and a line number in
|
||||
a book is wrong the moment they do.
|
||||
|
||||
**Chapter 5 is the one you can stop before.** Chapters 1 to 4 get a game onto the
|
||||
platform and everything in them moves one way — out of the game and onto a page.
|
||||
Chapter 5 is the other direction, and a deployment that never reads it still has a
|
||||
working event engine over core's own verbs. Chapters 3 and 4 each carry one section
|
||||
that only matters if you are going there (§2a and *"A command that changes the
|
||||
world runs at most once"*); both say so at the top.
|
||||
|
||||
## What is normative, and what is here
|
||||
|
||||
@@ -28,12 +37,14 @@ document is right and the chapter has a bug — [say so][issues]:
|
||||
| [`MODULE_API.md`][api] | Everything a module may do. |
|
||||
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
||||
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The game↔sidecar wire protocol, as one real sidecar implements it. |
|
||||
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, and what core owns. |
|
||||
|
||||
The chapters teach: the order to do things in, the reasoning, and the mistakes that
|
||||
cost this project time.
|
||||
|
||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
||||
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
|
||||
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||
|
||||
@@ -1,34 +1,60 @@
|
||||
{
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"branch": "main",
|
||||
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
|
||||
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
|
||||
"why": [
|
||||
"The core this kit is written against, pinned to a commit rather than a branch.",
|
||||
"This one is the Teams cutover, the commit MODULE_API_VERSION 1.6.0 reached",
|
||||
"`main` on, and 1.6.0 is what template/module.json declares. It moved here from",
|
||||
"the 1.5.0 bump because Teams expanded the contract the book teaches: the",
|
||||
"template now registers a Team provider and declares slots for core to fill,",
|
||||
"and both are members that exist only at this ref and later.",
|
||||
"This one is the EVENT SYSTEM cutover, the commit MODULE_API_VERSION 1.10.0",
|
||||
"reached `main` on (website#199), and 1.10.0 is what template/module.json",
|
||||
"declares. It moved here from 66bb3b9a (1.9.0, the engagement cutover) because",
|
||||
"the event contract expanded the book by a whole chapter: a module now declares",
|
||||
"what its game can DO on request -- event actions, budget dimensions, leases and",
|
||||
"option sources -- where every earlier chapter taught only a read path and a",
|
||||
"thing to announce.",
|
||||
"",
|
||||
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
|
||||
"version template/module.json declares still equals this core's",
|
||||
"MODULE_API_VERSION, so a contract bump turns this repo red on purpose",
|
||||
"(MODULE_SYSTEM.md 2.11.1 d2, 2.10).",
|
||||
"(MODULE_SYSTEM.md 2.11.1 d2, 2.10). Note what that means in the other",
|
||||
"direction, because it is easy to misread as a safety net: the check clones",
|
||||
"THIS ref, so a core that has moved past it does not turn the repo red on its",
|
||||
"own. Nothing goes red until someone moves the pin. Between cutovers the kit is",
|
||||
"not wrong, it is DATED - and this file is where the date is written down.",
|
||||
"",
|
||||
"That mechanism earned its keep this time. Writing the chapters against 1.6.0",
|
||||
"found that core's inverted-slot fills named three of module-uo's slots",
|
||||
"literally, so the direction worked for that one module and silently did",
|
||||
"nothing for any other game - an empty page with nothing logged. That is the",
|
||||
"exact class of thing a book written for an audience outside this org is meant",
|
||||
"to catch, and it was fixed in core before this pin moved.",
|
||||
"This pin move is a REPAIR as well as a date. Chapter 5 landed (#10) declaring",
|
||||
"^1.10.0 while this file still named a 1.9.0 core, so `main` has been red on",
|
||||
"checkCoreApi since it merged -- deliberately, and stated in that PR, but the",
|
||||
"red belongs to the window and not to the repo. This is the commit that was",
|
||||
"always going to close it, and it could not be written until the events sha",
|
||||
"existed on `main`. Same shape Teams phase 11 used.",
|
||||
"",
|
||||
"The mechanism earned its keep again here, and twice. Writing chapter 5 against",
|
||||
"the event contract found that an idempotency key on a QUESTION makes every",
|
||||
"later read permanently stale -- an at-most-once store answers a repeated key",
|
||||
"with the ORIGINAL reply, so the template's second read of a value returned the",
|
||||
"first read's answer for ever, and the module could not see a change it had just",
|
||||
"made. It also found that a refusal's reason goes in `error`: core's classifier",
|
||||
"reads no other name, so a refusal reported under `detail` reached an author as",
|
||||
"a bare \"refused\". Neither was found by writing prose. Both were found by",
|
||||
"running the template's real declarations through core's real registry and its",
|
||||
"real envelopes through core's real dispatcher.",
|
||||
"",
|
||||
"That is also why this file's own instruction is not enough on its own. The",
|
||||
"template job builds and tests the template against fakes and checks this",
|
||||
"number; it does not load the module into core. A declaration a fake accepts",
|
||||
"and core refuses would ship green, so a pin move is a run against a real core,",
|
||||
"not just an edit here. It was run at THIS ref: core's real registries accepted",
|
||||
"the template's budget, option source, lease and event action, and apply()",
|
||||
"accepted the set.",
|
||||
"",
|
||||
"The branch said `edge` until 2026-08-12, when the module system cut over and",
|
||||
"that branch was deleted (MODULE_SYSTEM.md 2.9). Teams cut a second `edge` and",
|
||||
"this pin skipped it entirely: the kit is written against what shipped, never",
|
||||
"against what is in flight. Nothing in CI reads the branch field - it clones",
|
||||
"the repo and checks out the sha - which is why a wrong label here would sit",
|
||||
"unnoticed. It is for the person deciding whether a newer core is worth",
|
||||
"re-reading the book for.",
|
||||
"that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an",
|
||||
"`edge` of their own and this pin skipped both; the Event System cut a third,",
|
||||
"and this pin skipped that too until it reached `main`. The kit is written",
|
||||
"against what shipped, never against what is in flight. Nothing in CI reads the",
|
||||
"branch field - it clones the repo and checks out the sha - which is why a wrong",
|
||||
"label here would sit unnoticed. It is for the person deciding whether a newer",
|
||||
"core is worth re-reading the book for.",
|
||||
"",
|
||||
"Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
|
||||
"sha, reviewable in a diff."
|
||||
|
||||
@@ -24,8 +24,14 @@ const { stripFences } = require('./lib/markdown')
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const QUIET = process.argv.includes('--quiet')
|
||||
|
||||
// Directories that hold no prose we own.
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
|
||||
// Directories that hold no prose we own. `.core` and `core` are core's own
|
||||
// checkout: .gitignore reserves both because moving `ci/core-ref.json` means
|
||||
// cloning core in here first, and without this that clone hands the reader nine
|
||||
// broken links in somebody else's README the moment they follow the pin-bump
|
||||
// instructions. CI never saw it — the clone happens in the `template` job and
|
||||
// this check runs in `prose` — which is exactly the kind of failure that only
|
||||
// ever meets a person.
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', '.core', 'core'])
|
||||
|
||||
/** Every markdown file in the repo, repo-relative, sorted. */
|
||||
function markdownFiles(dir = ROOT, out = []) {
|
||||
|
||||
@@ -15,6 +15,9 @@ Installed into a core, it adds:
|
||||
for the deployment — the one registration where core calls YOU and waits;
|
||||
- **three inverted extension slots**, declared by this module on the clan page for
|
||||
core to fill;
|
||||
- **four event declarations** — a budget dimension, an option source, a lease and
|
||||
one action that ledgers what it makes — so an event authored on the website can
|
||||
reach the game and be undone afterwards;
|
||||
- **both lifecycle hooks**, so there is something to see at boot and at shutdown.
|
||||
|
||||
That is deliberately less than your module will do. What it is *complete* about is
|
||||
@@ -28,7 +31,9 @@ module.json what core reads first — id, version, coreApi, moun
|
||||
server/
|
||||
index.js register(ctx, api) — the entire server-side handshake
|
||||
core.js the lazy accessors over ctx; read this second
|
||||
boot.js onBoot / onShutdown
|
||||
boot.js onBoot / onShutdown, and the game-restart watch
|
||||
sidecarClient.js the one file that talks to your sidecar — transport simulated
|
||||
config/eventActions.js budgets, option sources, leases and actions — read chapter 5
|
||||
db/schema.sql idempotent, replayed every boot
|
||||
db/purge.sql destructive, run only by an explicit admin purge
|
||||
model/worldStatus/ the .db.js / .model.js pair
|
||||
@@ -38,6 +43,7 @@ server/
|
||||
scripts/checkImports.js the module boundary, enforced
|
||||
scripts/swaggerFragment.js generates swagger-fragment.json from your own routes
|
||||
test/ the suites — start with entry.test.js
|
||||
test/eventActions.test.js the four traps chapter 5 is about, each as a failing test
|
||||
client/
|
||||
vite.config.js the library build: anchored aliases, external: []
|
||||
src/entry.jsx registers routes, nav and declared slots at evaluation time
|
||||
@@ -112,6 +118,8 @@ backticking table names**.
|
||||
| `.gitea/workflows/release.yml` | `GITEA_HOST` and `REPO`, under the `# CHANGE THESE` banner — the only two, and they are wrong until you do. (The `.github/` flavour needs nothing: GitHub supplies `GITHUB_REPOSITORY` and friends.) |
|
||||
| `server/package.json` | package `name` and `description` |
|
||||
| `server/core.js` | the message every accessor throws |
|
||||
| `server/index.js` | the trigger, audience, template and rule-group ids — all four are namespaced with your module id, and core refuses them otherwise |
|
||||
| `server/config/eventActions.js` | the budget, option-source, lease and action ids — four separate id spaces, each namespaced with your module id — and every command name the client sends |
|
||||
| `server/boot.js` | the placeholder world name |
|
||||
| `server/db/schema.sql` | every table name — the prefix must be your id |
|
||||
| `server/db/purge.sql` | the same table names |
|
||||
@@ -123,6 +131,8 @@ backticking table names**.
|
||||
| `server/swagger/doc.js` | the tag, and the `Examplegame…` schema prefix |
|
||||
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
|
||||
| `server/test/_fakes.js` | `ctx.moduleId` |
|
||||
| `server/test/entry.test.js` | the trigger id the world-status test asserts |
|
||||
| `server/test/eventActions.test.js` | the action and lease ids it looks up, and the clan fixture |
|
||||
| `server/test/worldStatus.test.js` | the fixture's world name |
|
||||
| `server/test/clanProvider.test.js` | the fixture's world name |
|
||||
| `server/package-lock.json` | **regenerated** — `npm install --prefix server` |
|
||||
@@ -145,6 +155,13 @@ placeholder and is not listed fails the build, and so does a listed file with
|
||||
nothing left to rename. A checklist nobody verifies is a checklist that is wrong
|
||||
by the second edit.
|
||||
|
||||
**`server/sidecarClient.js` is not on that list and is not an oversight.** It
|
||||
carries no placeholder id — its vocabulary is the game's, not the module's — so
|
||||
the checker has nothing to hold it to. It is still the file you have the most work
|
||||
in: replace `deliver()` with one request to your sidecar, replace the fake game's
|
||||
verbs with your game's, and set `TIMEOUT_MS` to what your transport actually
|
||||
waits. Chapter 5 is mostly about that file.
|
||||
|
||||
Two things you do **not** rename: the mount prefixes `/world` and `/clans` need
|
||||
not be your id (the server's prefix namespace is shared with core's — `/status`,
|
||||
`/settings`, `/version`, `/contact` and `/teams` are already taken, which is why
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "examplegame",
|
||||
"name": "Example Game",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.6.0",
|
||||
"coreApi": "^1.10.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
|
||||
@@ -31,6 +31,7 @@ const core = require('./core')
|
||||
|
||||
const worldStatusDb = require('./model/worldStatus/worldStatus.db')
|
||||
const clanDb = require('./model/clans/clanProvider.db')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
const log = core.logger('boot')
|
||||
|
||||
@@ -39,6 +40,10 @@ const log = core.logger('boot')
|
||||
// so that there is something for the shutdown hook to actually do.
|
||||
let refreshTimer = null
|
||||
|
||||
// The last boot id the game reported. `null` means "never observed", which is not
|
||||
// the same as "changed" — see `checkForRestart`.
|
||||
let lastBootId = null
|
||||
|
||||
const REFRESH_MS = 30 * 1000
|
||||
|
||||
/**
|
||||
@@ -52,12 +57,89 @@ async function refresh() {
|
||||
try {
|
||||
// A real module calls its sidecar's REST API here. Two hardcoded values
|
||||
// stand in, so that the page renders and the seam is visible.
|
||||
await worldStatusDb.setStatus({ online: true, players: 0, worldName: 'Example World' })
|
||||
const next = { online: true, players: 0, worldName: 'Example World' }
|
||||
|
||||
// ── Emitting a declared event ──────────────────────────────────────────
|
||||
//
|
||||
// **Emit on the TRANSITION, not on the poll.** This function runs every
|
||||
// thirty seconds; a rule on an event fired every thirty seconds is a rule
|
||||
// that mails somebody every thirty seconds. Core has a cooldown and an
|
||||
// hourly cap and they would both hold, but leaning on them means the module
|
||||
// is emitting "the world is still up" and calling it news. Read the previous
|
||||
// state, compare, and emit only when the answer changed.
|
||||
//
|
||||
// The read is BEFORE the write for the same reason, and getting that
|
||||
// backwards is the easy version of this bug: after `setStatus` the previous
|
||||
// value is gone and every poll looks like no change at all — an emitter that
|
||||
// never fires and never errors.
|
||||
const previous = await worldStatusDb.getStatus()
|
||||
await worldStatusDb.setStatus(next)
|
||||
|
||||
// Same poll, different question: did the thing we lit beacons in restart?
|
||||
checkForRestart()
|
||||
|
||||
// `previous === null` is the first boot on a fresh install, not a change.
|
||||
// Treating it as one would announce the world coming online to everyone the
|
||||
// first time an operator started the site.
|
||||
if (previous && Boolean(previous.online) !== next.online) {
|
||||
// Fire-and-forget: no await, no return value, nothing to handle. Core
|
||||
// validates the payload against what `index.js` declared, and what a
|
||||
// mismatch does depends on where you are running. **In production it is
|
||||
// dropped and logged** against this module, because a notification must
|
||||
// never be able to break the thing it is about. **Anywhere else it throws**,
|
||||
// at this line, so the stack points at your own call instead of at a
|
||||
// warning nobody reads. Neither is a condition to catch: a payload that
|
||||
// does not match the contract you declared is a bug to fix.
|
||||
core.emit('examplegame.world.status_changed', {
|
||||
data: {
|
||||
worldName: next.worldName,
|
||||
status: next.online ? 'online' : 'offline',
|
||||
players: next.players,
|
||||
url: '/world',
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('could not refresh world status', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notice that the game restarted, and tell core.
|
||||
*
|
||||
* **Core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
|
||||
* from a dispatch and cannot tell a wedged sidecar from a game that rebooted and
|
||||
* lost every beacon an event lit. Only this module knows, because only this
|
||||
* module watches the feed the boot id arrives on — which is also how you tell a
|
||||
* game restart from a sidecar reconnect, and they are not the same event: the
|
||||
* second loses nothing.
|
||||
*
|
||||
* So core asks once, at its own boot — the one reconnect it can see — and
|
||||
* otherwise waits to be told. `core.reconcileEvents()` is being told. It returns
|
||||
* at once and core sweeps its resource ledger on its own time, putting the
|
||||
* question back to this module as `reconcile({ runId, resources })` in
|
||||
* `config/eventActions.js`.
|
||||
*
|
||||
* Called from the same poll as everything else here, because a boot id is just
|
||||
* another thing the feed carries. In a real module this is a frame handler rather
|
||||
* than a comparison against a remembered value.
|
||||
*/
|
||||
function checkForRestart() {
|
||||
const bootId = sidecar.currentBootId()
|
||||
if (lastBootId === null) {
|
||||
// First observation is not a restart. Recording it as one would ask core to
|
||||
// reconcile every ledgered resource on every website deploy, which is a sweep
|
||||
// that costs a round trip per action for no news.
|
||||
lastBootId = bootId
|
||||
return
|
||||
}
|
||||
if (bootId === lastBootId) return
|
||||
|
||||
lastBootId = bootId
|
||||
log.info('game restarted, asking core to reconcile', { bootId })
|
||||
core.reconcileEvents()
|
||||
}
|
||||
|
||||
/**
|
||||
* Two clans, so that the Team provider has something to be authoritative about.
|
||||
*
|
||||
@@ -124,7 +206,8 @@ async function onBoot() {
|
||||
async function onShutdown() {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
lastBootId = null
|
||||
log.info('shut down')
|
||||
}
|
||||
|
||||
module.exports = { onBoot, onShutdown, refresh, seedClans, REFRESH_MS }
|
||||
module.exports = { onBoot, onShutdown, refresh, seedClans, checkForRestart, REFRESH_MS }
|
||||
|
||||
447
template/server/config/eventActions.js
Normal file
447
template/server/config/eventActions.js
Normal 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 }
|
||||
@@ -91,6 +91,38 @@ module.exports = {
|
||||
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
||||
get middleware() { return need().middleware },
|
||||
|
||||
// Firing a declared event (MODULE_API.md §2.3). Wrapped as a call rather than
|
||||
// exposed as `get events()`, so that `require('../core').emit` taken at file
|
||||
// scope still resolves `ctx` at call time like everything else here.
|
||||
//
|
||||
// **It returns nothing, and in production it never throws at the caller.** The
|
||||
// emit is the end of this module's involvement: core validates the payload
|
||||
// against the declared contract, decides which rules match, resolves who they
|
||||
// reach and sends. A module cannot address a person, choose a channel or write
|
||||
// a subject line, and this seam is deliberately too narrow to try (§2.7).
|
||||
//
|
||||
// Outside production a bad payload throws here rather than being logged, which
|
||||
// is the point: you meet the mismatch in your own tests instead of in an
|
||||
// operator's log six weeks later.
|
||||
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope),
|
||||
|
||||
// Telling core the game restarted (MODULE_API.md §2.3, 1.10.0). The one thing
|
||||
// the event contract adds to `ctx`, and it is here for a reason worth carrying:
|
||||
// **core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
|
||||
// and cannot tell a wedged sidecar from a shard that rebooted and lost every
|
||||
// creature an event spawned. Only this module knows, because only this module
|
||||
// watches the feed the boot id arrives on.
|
||||
//
|
||||
// Calling it asks core to sweep its resource ledger and put the question back
|
||||
// to this module's actions, as `reconcile({ runId, resources })`. Fire and
|
||||
// forget: it returns at once and the sweep happens on core's own time.
|
||||
//
|
||||
// See `boot.js` for the watch that calls it, and `config/eventActions.js` for
|
||||
// the answer. Named longer than the `ctx` member it wraps because this object
|
||||
// is flat — `core.emit` is already a little ambiguous and `core.reconcile()`
|
||||
// would be worse, since a module has more than one thing it could reconcile.
|
||||
reconcileEvents: () => need().events.reconcile(),
|
||||
|
||||
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
||||
// only correct way to find a file you shipped, because the working directory is
|
||||
// core's and the module's location is the loader's business.
|
||||
|
||||
@@ -50,6 +50,7 @@ module.exports = function register(ctx, api) {
|
||||
const worldRouter = require('./router/public/world.router')
|
||||
const clansRouter = require('./router/public/clans.router')
|
||||
const clanProvider = require('./model/clans/clanProvider.model')
|
||||
const eventActions = require('./config/eventActions')
|
||||
const boot = require('./boot')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -103,6 +104,190 @@ module.exports = function register(ctx, api) {
|
||||
// optional ones is an edit to the provider and not to this file.
|
||||
api.registerTeamProvider(clanProvider)
|
||||
|
||||
// ── Engagement: declaring what your game can announce ────────────────────
|
||||
//
|
||||
// The three calls below are one seam, and it is the one where a module is most
|
||||
// tempted to reach past the boundary. **You declare what CAN happen; core
|
||||
// decides who is told.** A module never names a person, a channel or an
|
||||
// address, and never sends anything (MODULE_API 1.7.0 and 1.9.0; §2.7).
|
||||
//
|
||||
// A TRIGGER is not a notification stream, and the two are easy to confuse
|
||||
// because both are catalogs of things that happen. A stream is a subscribe
|
||||
// toggle you publish to yourself. A trigger is a PAYLOAD CONTRACT an operator
|
||||
// writes rules against — it says what variables the event carries and how wide
|
||||
// an audience it may ever be given, and core does the sending. Their ids share
|
||||
// one namespace, so declaring both for one id is legal and is one event with a
|
||||
// toggle and a contract; taking an id another module owns is not.
|
||||
api.registerEventTriggers([
|
||||
{
|
||||
id: 'examplegame.world.status_changed',
|
||||
label: 'World came up or went down',
|
||||
description: 'The game server changed between online and offline.',
|
||||
kind: 'event',
|
||||
// The cooldown subject: "once per world", not "once per user". It must
|
||||
// NAME one of the variables below — core refuses the registration
|
||||
// otherwise, with this trigger's id in the message, and the module does not
|
||||
// load. That check exists because the failure it prevents is silent: a
|
||||
// subjectKey naming nothing keys every subject on `undefined`, which looks
|
||||
// exactly like the feature working right up until two worlds share it.
|
||||
subjectKey: 'worldName',
|
||||
audience: 'authenticated', // what a rule is CREATED with
|
||||
// ...and the widest it may EVER be given. Required, with no default,
|
||||
// because there is no safe value to guess: `owner` would silently break a
|
||||
// broadcast and `authenticated` would silently widen a staff-only event.
|
||||
// The values are ordered by CONTAINMENT, not by size — see chapter 2.
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
// Every variable needs an `example`, and it is not decoration: it is what
|
||||
// lets an operator preview and test-send a template without waiting for a
|
||||
// real game event, which is the reason template systems ship untested.
|
||||
{ name: 'worldName', type: 'string', required: true, example: 'Example World' },
|
||||
{ name: 'status', type: 'string', required: true, example: 'online' },
|
||||
{ name: 'players', type: 'int', required: false, example: 42 },
|
||||
// A `url` is validated SITE-RELATIVE, because it ends up in an href in a
|
||||
// mail somebody opens days later. Never a full URL of your own.
|
||||
{ name: 'url', type: 'url', required: false, example: '/world' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// An AUDIENCE is a named set of PEOPLE this module can resolve over its own
|
||||
// data, for an operator to point a rule at. "This clan's members" is one;
|
||||
// "everyone who opened the last mail" is not, and nothing here builds it.
|
||||
//
|
||||
// **The resolver returns user ids and nothing else.** It is not handed a
|
||||
// template, a channel or an address and it cannot enumerate them — core maps
|
||||
// ids to addresses on its own side, after preferences, suppression and the
|
||||
// verification gate. That is what stops this becoming the back door §2.7 spends
|
||||
// a section closing.
|
||||
//
|
||||
// Audiences are their OWN id space, unlike triggers and streams: an audience
|
||||
// names a set of people and a trigger names an event, so the two may share a
|
||||
// name without colliding.
|
||||
api.registerAudiences([
|
||||
{
|
||||
id: 'examplegame.clan.members',
|
||||
label: 'Members of a clan',
|
||||
// `int` or `string` only, and CONSTANT — an operator fills these in when
|
||||
// they save the rule. There is no way to say "the clan this event was
|
||||
// about"; if a rule needs that, the EVENT carries its own recipients
|
||||
// instead. Finding that out late is a phase's worth of rework.
|
||||
params: [{ id: 'clanId', type: 'string', required: true }],
|
||||
ceiling: 'members',
|
||||
resolve: async ({ clanId }) => clanProvider.listClanMemberUserIds({ clanId }),
|
||||
},
|
||||
])
|
||||
|
||||
// Finally the CONTENT: the bodies your messages use, and the rules that decide
|
||||
// when one is sent. Both arrive **switched off** — `enabled` is not a parameter
|
||||
// and there is no call that sets it. An operator turns a module's mail on;
|
||||
// installing a module never does.
|
||||
//
|
||||
// The two halves have different lifetimes, and the asymmetry is the contract:
|
||||
//
|
||||
// • **Templates are re-ensured on every boot**, under `seedVersion`, so
|
||||
// improving a default body reaches deployments that never edited it — and
|
||||
// one an operator HAS edited is marked customized and left alone. Bump
|
||||
// `seedVersion` when the body changes; never for a comment.
|
||||
// • **Rule groups are offered ONCE, per named group key.** Re-offering would
|
||||
// resurrect a rule an operator deleted and reset one they enabled. So a rule
|
||||
// appended to an existing group reaches FRESH INSTALLS ONLY. That is the
|
||||
// guarantee rather than a limitation to work around: a rule that has to
|
||||
// reach existing deployments takes a NEW group key, and you choose that
|
||||
// knowingly because you name the groups.
|
||||
//
|
||||
// Core's generic bodies are a first-class answer, not a fallback: point a
|
||||
// channel at `notify.event` / `inapp.event` / `notify.digest` and author
|
||||
// nothing. Ship a body of your own when the message has something to say that a
|
||||
// structural projection of the payload cannot. Below, the mail does — a world
|
||||
// coming back deserves a sentence — and the in-app item does not, so it uses
|
||||
// core's.
|
||||
//
|
||||
// Note the two casings, which are not a slip: a TEMPLATE is an object this call
|
||||
// shapes (`triggerId`), and a RULE is a row (`trigger_id`). Copy them as they
|
||||
// are.
|
||||
api.registerEngagementSeeds({
|
||||
templates: [
|
||||
{
|
||||
// MUST be namespaced `<moduleId>.` — the key column is unique across the
|
||||
// whole table, and an unprefixed `notify.event` from a module would
|
||||
// collide with core's own body and win.
|
||||
key: 'examplegame.world-status-changed',
|
||||
name: 'World — status changed',
|
||||
channel: 'email',
|
||||
subject: '{{worldName}} is {{status}}',
|
||||
triggerId: 'examplegame.world.status_changed',
|
||||
triggerVersion: 1,
|
||||
seedVersion: 1,
|
||||
// The same block objects the template editor writes, so an operator can
|
||||
// open this in the admin panel and keep editing from here.
|
||||
//
|
||||
// **This is the one thing in this file core does not check for you.**
|
||||
// `registerEngagementSeeds` asserts that `blocks` is a non-empty array and
|
||||
// stops; the BODY is validated by the block registry, which runs in the
|
||||
// editor and in the renderer. So a malformed block registers, seeds, and
|
||||
// first shows itself when an operator opens the body or a rule fires.
|
||||
// Two that are easy to get wrong: every block carries its own `id`, and
|
||||
// `email.heading`'s `level` is 'h1' | 'h2' | 'h3' — not a number.
|
||||
blocks: [
|
||||
{ id: 'h', type: 'email.heading', props: { level: 'h2', text: '{{worldName}} is {{status}}' } },
|
||||
{ id: 'intro', type: 'email.text', props: { text: 'There are {{players}} players online right now.' } },
|
||||
{ id: 'cta', type: 'email.button', props: { label: 'Open the world page', url: '{{url}}' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
ruleGroups: [
|
||||
{
|
||||
key: 'world-v1',
|
||||
note: 'the world status rule, seeded once',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'examplegame.world.status_changed',
|
||||
name: 'World status changes',
|
||||
audience: 'authenticated',
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: {
|
||||
email: 'examplegame.world-status-changed',
|
||||
inapp: 'inapp.event',
|
||||
},
|
||||
// Two ceilings on volume, and they answer different questions. The
|
||||
// cooldown is per SUBJECT — one mail per world per hour, however many
|
||||
// times it flaps. The hourly cap is per RULE, and is the thing that
|
||||
// keeps a misconfiguration from becoming a mail storm.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// ── Events: what a scheduled event may do to your game ───────────────────
|
||||
//
|
||||
// MODULE_API 1.10.0, `EVENTS.md` §F, and chapter 5 of this kit. Four
|
||||
// declarations, and the whole of the file they come from is about the four
|
||||
// rules that are invisible until an outage.
|
||||
//
|
||||
// **This is core CALLING YOU**, like the Team provider above and unlike
|
||||
// everything else in this function — but from further away than either, because
|
||||
// the thing on the other end is a game server. That distance is the reason an
|
||||
// action declares `budgetMs` and the reason its failure default is a retry.
|
||||
//
|
||||
// **Every one of the four is optional.** A module that registers none of them
|
||||
// leaves its deployment with an event engine that can announce, wait, cue a
|
||||
// human and publish results, which is a working product. Each one *adds* what
|
||||
// an author can reach for; none is load-bearing for the engine.
|
||||
//
|
||||
// Registered in this order because it is the order they depend on each other:
|
||||
// an action's `cost` may only name a budget some module declared, and a param's
|
||||
// `source` names an option source. Core resolves both after every module has
|
||||
// registered, so the order here is for a reader rather than for the loader.
|
||||
api.registerEventBudgets(eventActions.BUDGETS)
|
||||
api.registerEventOptionSources(eventActions.OPTION_SOURCES)
|
||||
api.registerEventLeases(eventActions.LEASES)
|
||||
api.registerEventActions(eventActions.ACTIONS)
|
||||
|
||||
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
||||
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
|
||||
// that must not serve traffic until it has warmed a cache gets that for free.
|
||||
|
||||
@@ -82,4 +82,25 @@ async function replaceClan({ externalId, name, abbr, memberCount, members }) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listClans, findClan, listMembers, replaceClan, CLANS, MEMBERS }
|
||||
/**
|
||||
* The site accounts behind one clan's roster — the whole of an audience resolver.
|
||||
*
|
||||
* `user_id` is NULL for most characters, and the filter is the point: an audience
|
||||
* resolves to PEOPLE WITH ACCOUNTS, and a character nobody has linked is not one.
|
||||
* Returning its NULL would hand core a hole in an array it is about to mail.
|
||||
*
|
||||
* DISTINCT because one person may hold several characters in the same clan, and
|
||||
* the resolver's contract is a set of users rather than a list of characters.
|
||||
* Without it a three-character player is told three times.
|
||||
*/
|
||||
async function listMemberUserIds(clanId) {
|
||||
const rows = await core.query(
|
||||
`SELECT DISTINCT user_id AS userId
|
||||
FROM ${MEMBERS}
|
||||
WHERE clan_id = ? AND user_id IS NOT NULL`,
|
||||
[clanId],
|
||||
)
|
||||
return rows.map((r) => r.userId)
|
||||
}
|
||||
|
||||
module.exports = { listClans, findClan, listMembers, listMemberUserIds, replaceClan, CLANS, MEMBERS }
|
||||
|
||||
@@ -292,6 +292,36 @@ async function projectRoster(externalId, members, viewer) {
|
||||
// a page that does not exist, which is worse.
|
||||
const pageUrlTemplate = '/examplegame/clans/{externalId}'
|
||||
|
||||
// ── The audience resolver ─────────────────────────────────────────────────
|
||||
//
|
||||
// Registered in `index.js` as `examplegame.clan.members` and called by core when
|
||||
// a rule pointed at that audience fires. It lives beside the provider because it
|
||||
// answers a question about the same rows, and it is NOT part of the provider —
|
||||
// core calls it through the audience registry, not through the five members
|
||||
// above.
|
||||
//
|
||||
// **Three rules, and every one of them protects somebody's mailbox rather than
|
||||
// this module's correctness.**
|
||||
//
|
||||
// 1. Return user ids and nothing else. You are not handed a template, a channel
|
||||
// or an address, and you may not enumerate them; core maps ids to addresses
|
||||
// on its own side, after preferences, suppression and the verification gate.
|
||||
// 2. Never widen on failure. A resolver that cannot answer returns the EMPTY set
|
||||
// — never "everyone", never the last good answer. Core treats a throw the
|
||||
// same way, but doing it here is what lets the log say which clan.
|
||||
// 3. It is a SET of people, not a list of characters. The `DISTINCT` is in the
|
||||
// query for that reason (see `clanProvider.db.js`).
|
||||
async function listClanMemberUserIds({ clanId }) {
|
||||
try {
|
||||
return await db.listMemberUserIds(clanId)
|
||||
} catch (err) {
|
||||
log.warn('could not resolve clan members; resolving to nobody', {
|
||||
clanId, message: err.message,
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTeams,
|
||||
getTeamMembers,
|
||||
@@ -300,4 +330,5 @@ module.exports = {
|
||||
rosterVisibleTo,
|
||||
pageUrlTemplate,
|
||||
gameIsReachable,
|
||||
listClanMemberUserIds,
|
||||
}
|
||||
|
||||
285
template/server/sidecarClient.js
Normal file
285
template/server/sidecarClient.js
Normal 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 }
|
||||
@@ -49,6 +49,15 @@ function fakeCtx(overrides = {}) {
|
||||
return log
|
||||
},
|
||||
auth: { getUserFromRequest: spy(null) },
|
||||
// The engagement seam (§2.3). One method, recording, because that is the
|
||||
// whole of what a module may do with it: fire a declared event and stop.
|
||||
// Core's own emit is fire-and-forget and returns nothing, so this does too —
|
||||
// a fake that returned a receipt would invite a module to wait on one.
|
||||
// `reconcile` joined it at 1.10.0 — the ONE thing the event contract adds to
|
||||
// `ctx`, because an action is called BY core and is handed what it needs in
|
||||
// the envelope. Only the module knows when the game restarted, so only the
|
||||
// module can ask for the sweep.
|
||||
events: { emit: spy(undefined), reconcile: spy(undefined) },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
@@ -85,7 +94,11 @@ function fakeCtx(overrides = {}) {
|
||||
* an operator's install.
|
||||
*/
|
||||
function fakeApi() {
|
||||
const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null }
|
||||
const record = {
|
||||
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
|
||||
triggers: null, audiences: null, engagementSeeds: null,
|
||||
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
|
||||
}
|
||||
const called = new Set()
|
||||
const once = (name) => {
|
||||
if (called.has(name)) throw new Error(`${name}() called twice`)
|
||||
@@ -103,6 +116,16 @@ function fakeApi() {
|
||||
// module registering a provider collides with the first. A fake cannot see
|
||||
// the second module, and asserting the half it can see is still worth doing.
|
||||
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
||||
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
||||
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
||||
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
||||
// The event contract (1.10.0). `once` on all four: a batch is a module's
|
||||
// COMPLETE statement about what it declares, so a second call is a module
|
||||
// changing its mind halfway through `register()` rather than adding to it.
|
||||
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
||||
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
|
||||
@@ -130,3 +130,224 @@ test('the manifest declares what the loader requires', () => {
|
||||
// serves, so an entry in the module root would publish the whole module.
|
||||
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
|
||||
})
|
||||
|
||||
// ── The engagement seam ───────────────────────────────────────────────────
|
||||
//
|
||||
// Core validates most of what is declared here at registration, and a module
|
||||
// that gets it wrong does not load. These tests are mostly NOT that validator
|
||||
// restated: they are the rules a module can satisfy at boot and still have got
|
||||
// WRONG in a way whose only symptom is mail somebody received. Where one does
|
||||
// overlap core — the namespacing and subjectKey assertions below — it is because
|
||||
// `npm test` is a cheaper place to meet the failure than a first boot, and the
|
||||
// message here names the field.
|
||||
|
||||
test('every declared trigger is namespaced, ceilinged, and carries examples', () => {
|
||||
const { api } = register()
|
||||
const triggers = api.record.triggers
|
||||
assert.ok(Array.isArray(triggers) && triggers.length, 'no triggers were declared')
|
||||
|
||||
for (const t of triggers) {
|
||||
// Trigger ids and notification-stream ids are ONE namespace, so an id must
|
||||
// carry this module's own prefix or it is a claim on somebody else's.
|
||||
assert.ok(t.id.startsWith(`${manifest.id}.`), `${t.id} is not namespaced`)
|
||||
|
||||
// `ceiling` is required and has no default: there is no safe value to guess.
|
||||
assert.ok(t.ceiling, `${t.id} declares no ceiling`)
|
||||
|
||||
// Core refuses this one too; failing it here just costs less. What the rule
|
||||
// protects is the cooldown key — "once per world", not "once per user" — and
|
||||
// a subjectKey naming nothing would key every subject on `undefined`.
|
||||
const names = t.variables.map((v) => v.name)
|
||||
assert.ok(names.includes(t.subjectKey), `${t.id}: subjectKey "${t.subjectKey}" is not a variable`)
|
||||
|
||||
for (const v of t.variables) {
|
||||
// Not decoration: the example is what makes a template previewable and
|
||||
// test-sendable without waiting for a real game event.
|
||||
assert.ok('example' in v, `${t.id}.${v.name} has no example`)
|
||||
// The type set is closed. A payload that needs a structure has outgrown
|
||||
// interpolation, and a template cannot walk one.
|
||||
assert.ok(
|
||||
['string', 'int', 'float', 'boolean', 'datetime', 'url'].includes(v.type),
|
||||
`${t.id}.${v.name} has type "${v.type}"`,
|
||||
)
|
||||
// A url is site-relative, because it ends up in an href in a mail somebody
|
||||
// opens days later.
|
||||
if (v.type === 'url') assert.match(v.example, /^\/[^/]/, `${t.id}.${v.name} must be site-relative`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('an audience resolves to nobody rather than to everybody when it fails', async () => {
|
||||
const ctx = fakeCtx({ db: { query: () => Promise.reject(new Error('database is down')), pool: {} } })
|
||||
const { api } = register(ctx)
|
||||
const audience = api.record.audiences[0]
|
||||
|
||||
// The one behaviour worth a test of its own. Core treats a throw the same way,
|
||||
// so this is not core's guard restated — it is the module choosing the same
|
||||
// answer deliberately, and the reason is that the alternatives are both worse:
|
||||
// "everyone" mails the wrong people and a stale answer mails yesterday's.
|
||||
assert.deepStrictEqual(await audience.resolve({ clanId: 'clan-1' }), [])
|
||||
})
|
||||
|
||||
test('a seeded rule names only this module’s triggers, and its own or core’s templates', () => {
|
||||
const { api } = register()
|
||||
const seeds = api.record.engagementSeeds
|
||||
const ownKeys = new Set(seeds.templates.map((t) => t.key))
|
||||
const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest'])
|
||||
|
||||
for (const t of seeds.templates) {
|
||||
// The key column is UNIQUE across the whole table, so an unprefixed
|
||||
// `notify.event` from a module would collide with core's body and win.
|
||||
assert.ok(t.key.startsWith(`${manifest.id}.`), `template ${t.key} is not namespaced`)
|
||||
// `protected` means "the system breaks without this body" — true of a
|
||||
// password reset and of nothing a module ships. Core refuses a module
|
||||
// template that sets it, because it would take an operator's delete button
|
||||
// away.
|
||||
assert.ok(!('protected' in t), `template ${t.key} may not mark itself protected`)
|
||||
}
|
||||
|
||||
for (const group of seeds.ruleGroups) {
|
||||
for (const rule of group.rules) {
|
||||
assert.ok(
|
||||
api.record.triggers.some((t) => t.id === rule.trigger_id),
|
||||
`${rule.name} names a trigger this module does not declare`,
|
||||
)
|
||||
for (const key of Object.values(rule.template_keys)) {
|
||||
assert.ok(ownKeys.has(key) || coreKeys.has(key), `${rule.name} names an unknown template ${key}`)
|
||||
}
|
||||
// `enabled` is not a parameter, and a value passed for it is ignored
|
||||
// rather than refused. Passing one anyway states an intention the platform
|
||||
// will not honour, so the honest thing is not to write it.
|
||||
assert.ok(!('enabled' in rule), `${rule.name} may not seed itself enabled`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every seeded body is a shape the block registry will accept', () => {
|
||||
const { api } = register()
|
||||
|
||||
// The gap this exists for: `registerEngagementSeeds` checks that `blocks` is a
|
||||
// non-empty array and stops. The BODY is validated by core's block registry,
|
||||
// which runs in the template editor and in the renderer — so a malformed block
|
||||
// registers, seeds, and first shows itself when an operator opens the body or a
|
||||
// rule fires. Core is not here to ask, so assert the two rules that are easy to
|
||||
// get wrong and impossible to notice.
|
||||
const HEADING_LEVELS = ['h1', 'h2', 'h3']
|
||||
|
||||
for (const t of api.record.engagementSeeds.templates) {
|
||||
const ids = new Set()
|
||||
for (const block of t.blocks) {
|
||||
// Every block carries its own id, unique within the body: it is how the
|
||||
// editor addresses one block, and how `inapp.event`'s renderer maps blocks
|
||||
// onto the inbox row's columns by role.
|
||||
assert.ok(block.id && typeof block.id === 'string', `${t.key}: a block has no id`)
|
||||
assert.ok(!ids.has(block.id), `${t.key}: two blocks share the id "${block.id}"`)
|
||||
ids.add(block.id)
|
||||
assert.ok(block.type.startsWith('email.'), `${t.key}: ${block.type} is not an email block`)
|
||||
|
||||
// A heading's `level` is a SIZE token, not a number. `{ level: 2 }` reads
|
||||
// perfectly and is refused, and it renders at the default size in any
|
||||
// preview that skips validation — which is the whole trap.
|
||||
if (block.type === 'email.heading') {
|
||||
assert.ok(
|
||||
HEADING_LEVELS.includes(block.props.level),
|
||||
`${t.key}: heading level "${block.props.level}" must be one of ${HEADING_LEVELS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('the world event fires on the transition and not on the poll', async () => {
|
||||
const boot = require('../boot')
|
||||
|
||||
// Online already, and reporting online again. The refresh writes, and nothing
|
||||
// is announced: this runs every thirty seconds, and a rule on an event fired
|
||||
// every thirty seconds mails somebody every thirty seconds. Core's cooldown
|
||||
// would hold — but leaning on it means emitting "still up" and calling it news.
|
||||
const steady = fakeCtx({ db: { query: () => Promise.resolve([{ online: 1 }]), pool: {} } })
|
||||
require('../core')._reset()
|
||||
require('../core').init(steady)
|
||||
await boot.refresh()
|
||||
assert.deepStrictEqual(steady.events.emit.calls, [])
|
||||
|
||||
// Offline before, online now. One emit, with the declared payload.
|
||||
const flipped = fakeCtx({ db: { query: () => Promise.resolve([{ online: 0 }]), pool: {} } })
|
||||
require('../core')._reset()
|
||||
require('../core').init(flipped)
|
||||
await boot.refresh()
|
||||
assert.strictEqual(flipped.events.emit.calls.length, 1)
|
||||
const [triggerId, envelope] = flipped.events.emit.calls[0]
|
||||
assert.strictEqual(triggerId, 'examplegame.world.status_changed')
|
||||
assert.strictEqual(envelope.data.status, 'online')
|
||||
|
||||
// A fresh install, where there is no previous row at all. Not a change — and
|
||||
// announcing it would tell everyone the world came online the first time an
|
||||
// operator started the site.
|
||||
const fresh = fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })
|
||||
require('../core')._reset()
|
||||
require('../core').init(fresh)
|
||||
await boot.refresh()
|
||||
assert.deepStrictEqual(fresh.events.emit.calls, [])
|
||||
})
|
||||
|
||||
test('the four event declarations are registered, each exactly once', () => {
|
||||
const { api } = register()
|
||||
|
||||
// Every one of the four is optional (§F), so this asserts what THIS module
|
||||
// chose rather than what core requires. What it is really checking is that
|
||||
// `index.js` still hands core the arrays `config/eventActions.js` exports —
|
||||
// the failure it catches is a rename on one side and not the other, which
|
||||
// costs a deployment a capability with nothing red anywhere.
|
||||
assert.ok(Array.isArray(api.record.eventBudgets))
|
||||
assert.ok(Array.isArray(api.record.eventOptionSources))
|
||||
assert.ok(Array.isArray(api.record.eventLeases))
|
||||
assert.ok(Array.isArray(api.record.eventActions))
|
||||
|
||||
// `once` on all four: a batch is a module's COMPLETE statement about what it
|
||||
// declares. `fakeApi` throws on a second call, so registering twice fails here.
|
||||
assert.ok(api.record.eventActions.length > 0)
|
||||
})
|
||||
|
||||
test('an action may only spend a budget dimension some module declared', () => {
|
||||
const { api } = register()
|
||||
|
||||
// Core refuses a `cost()` naming an undeclared dimension at save, at the dry
|
||||
// run and at dispatch, because the fix is a module's declaration rather than a
|
||||
// deployment's cap. This module declares everything it spends, so the check is
|
||||
// local; a module spending another module's dimension would have to loosen it.
|
||||
const declared = new Set(api.record.eventBudgets.map((b) => b.id))
|
||||
for (const action of api.record.eventActions) {
|
||||
const sample = Object.fromEntries(action.params.map((p) => [p.name, p.example]))
|
||||
for (const dimension of Object.keys(action.cost(sample))) {
|
||||
assert.ok(declared.has(dimension), `${action.id} spends undeclared ${dimension}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a game restart asks core to reconcile, and a first sighting does not', () => {
|
||||
const boot = require('../boot')
|
||||
const sidecar = require('../sidecarClient')
|
||||
|
||||
const ctx = fakeCtx()
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
|
||||
// First observation is not a restart. Treating it as one would sweep every
|
||||
// ledgered resource on every website deploy, for no news.
|
||||
boot.checkForRestart()
|
||||
assert.deepStrictEqual(ctx.events.reconcile.calls, [])
|
||||
|
||||
// Same boot id: still nothing.
|
||||
boot.checkForRestart()
|
||||
assert.deepStrictEqual(ctx.events.reconcile.calls, [])
|
||||
|
||||
// The game came back as something else. Core cannot see this and must be told.
|
||||
sidecar.simulateRestart()
|
||||
boot.checkForRestart()
|
||||
assert.strictEqual(ctx.events.reconcile.calls.length, 1)
|
||||
|
||||
// And only once for one restart.
|
||||
boot.checkForRestart()
|
||||
assert.strictEqual(ctx.events.reconcile.calls.length, 1)
|
||||
})
|
||||
|
||||
399
template/server/test/eventActions.test.js
Normal file
399
template/server/test/eventActions.test.js
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user