Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e852e5574d | |||
| ad37cade6e | |||
| 7f746aee3d | |||
| 5dc14fa626 | |||
| a72b002f75 | |||
| f89044b42e |
@@ -57,7 +57,10 @@ typing commands can only be polled, and polling turns "someone left at 14:02" in
|
|||||||
the contract cannot do yet.
|
the contract cannot do yet.
|
||||||
2. **`template/`** — a module that builds and loads, doing almost nothing. Copy it,
|
2. **`template/`** — a module that builds and loads, doing almost nothing. Copy it,
|
||||||
rename it, and you have a running module before you have read a chapter.
|
rename it, and you have a running module before you have read a chapter.
|
||||||
3. **The book** — [`book/`](book/), four chapters, in the order the work happens.
|
3. **The book** — [`book/`](book/), five chapters, in the order the work happens.
|
||||||
|
The first four are the job. The fifth is optional and comes after you have a
|
||||||
|
working module: what to declare if you want a scheduled event on the website to
|
||||||
|
be able to change your live world, and get it back afterwards.
|
||||||
|
|
||||||
## The one rule this kit follows
|
## The one rule this kit follows
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ kit and one of them disagree, they win and the kit has a bug:
|
|||||||
| [`MODULE_API.md`][api] | Everything a module may do: `module.json`, `ctx`, the `register*` calls, the client registry, the UI kit, schema-fragment rules, the loader's obligations. |
|
| [`MODULE_API.md`][api] | Everything a module may do: `module.json`, `ctx`, the `register*` calls, the client registry, the UI kit, schema-fragment rules, the loader's obligations. |
|
||||||
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
||||||
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The shard↔sidecar wire protocol, as one real sidecar implements it. |
|
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The shard↔sidecar wire protocol, as one real sidecar implements it. |
|
||||||
|
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, what core owns, and every rule chapter 5 explains the reasoning behind. |
|
||||||
|
|
||||||
The kit *teaches*: the order to do things in, the reasoning, worked examples, and
|
The kit *teaches*: the order to do things in, the reasoning, worked examples, and
|
||||||
the mistakes that cost this project time. Where it must show a member list it
|
the mistakes that cost this project time. Where it must show a member list it
|
||||||
@@ -104,6 +108,7 @@ same licence, and so does anything derived from it.
|
|||||||
[module-uo]: https://gitea.whitlocktech.com/RunicGateway/Module-uo
|
[module-uo]: https://gitea.whitlocktech.com/RunicGateway/Module-uo
|
||||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
||||||
|
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
|
||||||
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||||
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||||
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||||
|
|||||||
@@ -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
|
put what arrives while the website is not running, because the website not running
|
||||||
is exactly the case.
|
is exactly the case.
|
||||||
|
|
||||||
|
## 2a. The other direction, if you ever want events
|
||||||
|
|
||||||
|
Everything above is about data leaving the game. Skip this section until you want
|
||||||
|
[chapter 5](05-events.md) — but read it *before* you build the sidecar rather than
|
||||||
|
after, because retrofitting it is more work than allowing for it.
|
||||||
|
|
||||||
|
An event on the website is core telling your module *"do this to the world now"*,
|
||||||
|
and your module telling your sidecar, and your sidecar telling the game. That is a
|
||||||
|
**command** — a request with a reply, going the way nothing above goes. It needs
|
||||||
|
three things the read path does not.
|
||||||
|
|
||||||
|
**Request/reply correlation.** A command is not a broadcast: the caller waits for
|
||||||
|
an answer and has to know which answer is theirs. `uo-link` does this in
|
||||||
|
`sidecar/src/rpc.rs` — an id on the way out, a map of pending calls, the reply
|
||||||
|
matched back and the waiter woken. You need it for reads that ask the game a live
|
||||||
|
question too, so it is often already there; commands are what make it load-bearing.
|
||||||
|
|
||||||
|
**An idempotency key, executed at most once, stored where the game is.** Core hands
|
||||||
|
your module a key that is a function of the step's identity and never of the
|
||||||
|
attempt, so every retry carries the same one. The far end must execute a given key
|
||||||
|
once and answer a repeat with **the reply the first attempt produced** — not by
|
||||||
|
running the command again.
|
||||||
|
|
||||||
|
That store belongs as close to the game as the state it protects. A store in the
|
||||||
|
sidecar is right for a command whose effect is the sidecar's own; a command that
|
||||||
|
changes the *world* needs the store where the world is, because the case it exists
|
||||||
|
for is the game restarting mid-run. And a repeat arriving while the original is
|
||||||
|
still in flight is its own answer — "busy", transient by construction, because the
|
||||||
|
work is happening.
|
||||||
|
|
||||||
|
Without this, a command that arrived, ran, and whose acknowledgement was lost is
|
||||||
|
indistinguishable from one that never arrived. The only safe policy is then never
|
||||||
|
to retry, which means a game restarting mid-event writes the step off.
|
||||||
|
|
||||||
|
**A deadline the game enforces on its own.** A borrowed value — a doubled gather
|
||||||
|
rate, a raised spawn cap — carries an expiry down the wire, and the game side must
|
||||||
|
restore the baseline when it passes **without being asked again**. The website's
|
||||||
|
copy of that deadline is for the console. The game's copy is the fail-safe: if the
|
||||||
|
website is never heard from again, the value still comes back.
|
||||||
|
|
||||||
|
Two details that are easy to get wrong and expensive to change later. Send the
|
||||||
|
deadline as a **duration**, not an absolute time — two machines' clocks are two
|
||||||
|
clocks. And if the borrowed value lives in the game's own save file, the *hold*
|
||||||
|
must be persisted and the timer re-armed at load; a restart preserves the change
|
||||||
|
and destroys only the thing that would have undone it.
|
||||||
|
|
||||||
|
## 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
|
## 3. The wire is a versioned contract, not a build dependency
|
||||||
|
|
||||||
Your sidecar and your module ship separately, on different schedules, to hosts you
|
Your sidecar and your module ship separately, on different schedules, to hosts you
|
||||||
@@ -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
|
[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
|
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||||
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||||
|
[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
|
[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
|
anything it throws, because the alternative is an exception unwinding somewhere in
|
||||||
the engine's main loop.
|
the engine's main loop.
|
||||||
|
|
||||||
|
## A command that changes the world runs at most once
|
||||||
|
|
||||||
|
Skip this until you want [chapter 5](05-events.md). Everything above assumes an
|
||||||
|
inbound line either asks a question or is a one-off an operator typed. An **event**
|
||||||
|
is neither: it is unattended, it is retried, and what it does is permanent.
|
||||||
|
|
||||||
|
Three obligations, and they all live on this side of the wire because this is the
|
||||||
|
side that has the world.
|
||||||
|
|
||||||
|
**Keep a key store, and persist it.** Every command an event sends carries an
|
||||||
|
idempotency key — a function of the step's identity, never of the attempt, so a
|
||||||
|
retry carries the one the first attempt did. Before executing, look the key up:
|
||||||
|
|
||||||
|
- **not seen** — execute, then record the key *with the reply you are about to
|
||||||
|
send*;
|
||||||
|
- **seen and finished** — send that stored reply back, unchanged. Do not re-run;
|
||||||
|
- **seen and still running** — answer "busy". It is transient by construction, and
|
||||||
|
the caller will retry; running it concurrently with itself is the failure.
|
||||||
|
|
||||||
|
The stored reply matters as much as the guard. A repeat that re-ran and returned a
|
||||||
|
*new* serial would be two things in the world and one in the website's ledger,
|
||||||
|
which is the exact failure the key exists to prevent, arrived at by a longer route.
|
||||||
|
|
||||||
|
**Persist it in the world save, not in memory**, if what the command creates
|
||||||
|
survives a restart. The case this whole mechanism exists for is a game restarting
|
||||||
|
mid-event, and a key store that dies with the process is a store that is empty in
|
||||||
|
precisely that case.
|
||||||
|
|
||||||
|
**Own what an event made, and expire what it borrowed.**
|
||||||
|
|
||||||
|
An event-created thing has to be findable again later, because the website will ask
|
||||||
|
you to remove it after the event and may ask more than once. That means a registry
|
||||||
|
— a persisted map from the website's reference to the object — and it means
|
||||||
|
`remove` is idempotent: **removing something that is not there is a success.** The
|
||||||
|
website records a resource *before* it is confirmed, so it will ask you about
|
||||||
|
things that may never have existed, and neither end can tell the difference.
|
||||||
|
|
||||||
|
A borrowed value is the mirror. It arrives with a duration, and you arm a timer that
|
||||||
|
puts the baseline back when it expires. If the borrowed value lives in the save
|
||||||
|
file, persist the hold and **re-arm the timer at load** — a restart preserves the
|
||||||
|
change and destroys only the thing that would have undone it. Restore with a
|
||||||
|
compare-and-set against what you applied: if a staff member has moved it by hand
|
||||||
|
since, report that rather than overwriting them.
|
||||||
|
|
||||||
|
The through-line: **the game enforces the expiry, not the website.** If the website
|
||||||
|
is never heard from again, every borrowed value still comes back on its own.
|
||||||
|
|
||||||
## Reconnect, and what to send on connect
|
## Reconnect, and what to send on connect
|
||||||
|
|
||||||
Your sidecar restarts independently of your game. It comes back with an empty
|
Your sidecar restarts independently of your game. It comes back with an empty
|
||||||
@@ -173,12 +220,25 @@ Two practical notes from that file, both general:
|
|||||||
If all eight hold, the worst a broken sidecar can do to your game is nothing at
|
If all eight hold, the worst a broken sidecar can do to your game is nothing at
|
||||||
all — which is the entire point of the arrangement.
|
all — which is the entire point of the arrangement.
|
||||||
|
|
||||||
|
Three more, and only if you took commands (chapter 5):
|
||||||
|
|
||||||
|
9. A command's idempotency key is looked up before it is executed, and a repeat is
|
||||||
|
answered with the stored reply rather than re-run.
|
||||||
|
10. What an event made is in a persisted registry, and removing something absent is
|
||||||
|
a success.
|
||||||
|
11. A borrowed value's expiry is armed by this side, re-armed at load, and restored
|
||||||
|
with a compare-and-set.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
That is the book. The three parts are a module core loads, a sidecar that owns the
|
That is the book. The three parts are a module core loads, a sidecar that owns the
|
||||||
game connection and the durable copy of what it said, and a plugin that feeds the
|
game connection and the durable copy of what it said, and a plugin that feeds the
|
||||||
sidecar without ever waiting on it.
|
sidecar without ever waiting on it.
|
||||||
|
|
||||||
|
[Chapter 5](05-events.md) is the optional fifth part: what to declare if you want
|
||||||
|
the website to be able to change your world on a schedule, and the four mistakes
|
||||||
|
that make that unsafe.
|
||||||
|
|
||||||
If you got this far and built something, the places you got stuck are the most
|
If you got this far and built something, the places you got stuck are the most
|
||||||
valuable thing this repo can receive — [tell us][issues], and please say where you
|
valuable thing this repo can receive — [tell us][issues], and please say where you
|
||||||
left the kit and what you did next.
|
left the kit and what you did next.
|
||||||
|
|||||||
364
book/05-events.md
Normal file
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
|
# The book
|
||||||
|
|
||||||
Four chapters, in the order the work happens.
|
Five chapters, in the order the work happens. The first four are the job; the
|
||||||
|
fifth is optional and comes after you have one.
|
||||||
|
|
||||||
Read [the dry run][dryrun] before any of them — a complete module designed on
|
Read [the dry run][dryrun] before any of them — a complete module designed on
|
||||||
paper for a second game, and the shortest honest picture of the whole job.
|
paper for a second game, and the shortest honest picture of the whole job.
|
||||||
@@ -11,12 +12,20 @@ paper for a second game, and the shortest honest picture of the whole job.
|
|||||||
| 2 | [The website module](02-website-module.md) | The bulk of the work: `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do. |
|
| 2 | [The website module](02-website-module.md) | The bulk of the work: `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do. |
|
||||||
| 3 | [The sidecar](03-sidecar.md) | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is. |
|
| 3 | [The sidecar](03-sidecar.md) | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is. |
|
||||||
| 4 | [The game-side plugin](04-game-plugin.md) | The least code and the highest stakes: never block the game thread. |
|
| 4 | [The game-side plugin](04-game-plugin.md) | The least code and the highest stakes: never block the game thread. |
|
||||||
|
| 5 | [Making your module event-capable](05-events.md) | Optional, and the first thing here that can do damage: letting a scheduled event on the website change your live world, and get it back. |
|
||||||
|
|
||||||
Chapters 1 and 2 quote `template/`, which CI builds against a pinned core, so their
|
Chapters 1, 2 and 5 quote `template/`, which CI builds against a pinned core, so
|
||||||
code is a tree that is proved rather than prose that looks like one. Chapters 3 and
|
their code is a tree that is proved rather than prose that looks like one. Chapters
|
||||||
4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by line, on
|
3 and 4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by
|
||||||
purpose: those repositories move for their own reasons and a line number in a book
|
line, on purpose: those repositories move for their own reasons and a line number in
|
||||||
is wrong the moment they do.
|
a book is wrong the moment they do.
|
||||||
|
|
||||||
|
**Chapter 5 is the one you can stop before.** Chapters 1 to 4 get a game onto the
|
||||||
|
platform and everything in them moves one way — out of the game and onto a page.
|
||||||
|
Chapter 5 is the other direction, and a deployment that never reads it still has a
|
||||||
|
working event engine over core's own verbs. Chapters 3 and 4 each carry one section
|
||||||
|
that only matters if you are going there (§2a and *"A command that changes the
|
||||||
|
world runs at most once"*); both say so at the top.
|
||||||
|
|
||||||
## What is normative, and what is here
|
## What is normative, and what is here
|
||||||
|
|
||||||
@@ -28,12 +37,14 @@ document is right and the chapter has a bug — [say so][issues]:
|
|||||||
| [`MODULE_API.md`][api] | Everything a module may do. |
|
| [`MODULE_API.md`][api] | Everything a module may do. |
|
||||||
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
||||||
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The game↔sidecar wire protocol, as one real sidecar implements it. |
|
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The game↔sidecar wire protocol, as one real sidecar implements it. |
|
||||||
|
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, and what core owns. |
|
||||||
|
|
||||||
The chapters teach: the order to do things in, the reasoning, and the mistakes that
|
The chapters teach: the order to do things in, the reasoning, and the mistakes that
|
||||||
cost this project time.
|
cost this project time.
|
||||||
|
|
||||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
||||||
|
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
|
||||||
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||||
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||||
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"ref": "66bb3b9a3fad01112c06f32d931c9bae56d22de6",
|
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
|
||||||
"why": [
|
"why": [
|
||||||
"The core this kit is written against, pinned to a commit rather than a branch.",
|
"The core this kit is written against, pinned to a commit rather than a branch.",
|
||||||
"This one is the engagement cutover, the commit MODULE_API_VERSION 1.9.0 reached",
|
"This one is the EVENT SYSTEM cutover, the commit MODULE_API_VERSION 1.10.0",
|
||||||
"`main` on, and 1.9.0 is what template/module.json declares. It moved here from",
|
"reached `main` on (website#199), and 1.10.0 is what template/module.json",
|
||||||
"1.6.0 (the Teams cutover) because engagement expanded the contract the book",
|
"declares. It moved here from 66bb3b9a (1.9.0, the engagement cutover) because",
|
||||||
"teaches by three registrations and two calls: a module now declares what its",
|
"the event contract expanded the book by a whole chapter: a module now declares",
|
||||||
"game can announce and never who is told.",
|
"what its game can DO on request -- event actions, budget dimensions, leases and",
|
||||||
|
"option sources -- where every earlier chapter taught only a read path and a",
|
||||||
|
"thing to announce.",
|
||||||
"",
|
"",
|
||||||
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
|
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
|
||||||
"version template/module.json declares still equals this core's",
|
"version template/module.json declares still equals this core's",
|
||||||
@@ -19,29 +21,40 @@
|
|||||||
"own. Nothing goes red until someone moves the pin. Between cutovers the kit is",
|
"own. Nothing goes red until someone moves the pin. Between cutovers the kit is",
|
||||||
"not wrong, it is DATED - and this file is where the date is written down.",
|
"not wrong, it is DATED - and this file is where the date is written down.",
|
||||||
"",
|
"",
|
||||||
"The mechanism earned its keep again here. Writing chapter 2's engagement",
|
"This pin move is a REPAIR as well as a date. Chapter 5 landed (#10) declaring",
|
||||||
"section against 1.9.0 found that the seeded body a module ships is the one",
|
"^1.10.0 while this file still named a 1.9.0 core, so `main` has been red on",
|
||||||
"thing registerEngagementSeeds does not validate - it checks that `blocks` is a",
|
"checkCoreApi since it merged -- deliberately, and stated in that PR, but the",
|
||||||
"non-empty array and stops - so the template's own example body had a heading",
|
"red belongs to the window and not to the repo. This is the commit that was",
|
||||||
"level of 2 where the block registry takes 'h2', and no block ids at all. It",
|
"always going to close it, and it could not be written until the events sha",
|
||||||
"would have registered, seeded, and failed the first time an operator opened it.",
|
"existed on `main`. Same shape Teams phase 11 used.",
|
||||||
"Caught by running the template's register() through core's real registry at",
|
|
||||||
"this ref, which is what a re-read is for; both the fix and the gap are now in",
|
|
||||||
"the chapter and beside the code.",
|
|
||||||
"",
|
"",
|
||||||
"That gap is also why this file's own instruction is not enough on its own. The",
|
"The mechanism earned its keep again here, and twice. Writing chapter 5 against",
|
||||||
|
"the event contract found that an idempotency key on a QUESTION makes every",
|
||||||
|
"later read permanently stale -- an at-most-once store answers a repeated key",
|
||||||
|
"with the ORIGINAL reply, so the template's second read of a value returned the",
|
||||||
|
"first read's answer for ever, and the module could not see a change it had just",
|
||||||
|
"made. It also found that a refusal's reason goes in `error`: core's classifier",
|
||||||
|
"reads no other name, so a refusal reported under `detail` reached an author as",
|
||||||
|
"a bare \"refused\". Neither was found by writing prose. Both were found by",
|
||||||
|
"running the template's real declarations through core's real registry and its",
|
||||||
|
"real envelopes through core's real dispatcher.",
|
||||||
|
"",
|
||||||
|
"That is also why this file's own instruction is not enough on its own. The",
|
||||||
"template job builds and tests the template against fakes and checks this",
|
"template job builds and tests the template against fakes and checks this",
|
||||||
"number; it does not load the module into core. A declaration a fake accepts",
|
"number; it does not load the module into core. A declaration a fake accepts",
|
||||||
"and core refuses would ship green, so a pin move is a run against a real core,",
|
"and core refuses would ship green, so a pin move is a run against a real core,",
|
||||||
"not just an edit here.",
|
"not just an edit here. It was run at THIS ref: core's real registries accepted",
|
||||||
|
"the template's budget, option source, lease and event action, and apply()",
|
||||||
|
"accepted the set.",
|
||||||
"",
|
"",
|
||||||
"The branch said `edge` until 2026-08-12, when the module system cut over and",
|
"The branch said `edge` until 2026-08-12, when the module system cut over and",
|
||||||
"that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an",
|
"that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an",
|
||||||
"`edge` of their own and this pin skipped both: the kit is written against what",
|
"`edge` of their own and this pin skipped both; the Event System cut a third,",
|
||||||
"shipped, never against what is in flight. Nothing in CI reads the branch field",
|
"and this pin skipped that too until it reached `main`. The kit is written",
|
||||||
"- it clones the repo and checks out the sha - which is why a wrong label here",
|
"against what shipped, never against what is in flight. Nothing in CI reads the",
|
||||||
"would sit unnoticed. It is for the person deciding whether a newer core is",
|
"branch field - it clones the repo and checks out the sha - which is why a wrong",
|
||||||
"worth re-reading the book for.",
|
"label here would sit unnoticed. It is for the person deciding whether a newer",
|
||||||
|
"core is worth re-reading the book for.",
|
||||||
"",
|
"",
|
||||||
"Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
|
"Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
|
||||||
"sha, reviewable in a diff."
|
"sha, reviewable in a diff."
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ Installed into a core, it adds:
|
|||||||
for the deployment — the one registration where core calls YOU and waits;
|
for the deployment — the one registration where core calls YOU and waits;
|
||||||
- **three inverted extension slots**, declared by this module on the clan page for
|
- **three inverted extension slots**, declared by this module on the clan page for
|
||||||
core to fill;
|
core to fill;
|
||||||
|
- **four event declarations** — a budget dimension, an option source, a lease and
|
||||||
|
one action that ledgers what it makes — so an event authored on the website can
|
||||||
|
reach the game and be undone afterwards;
|
||||||
- **both lifecycle hooks**, so there is something to see at boot and at shutdown.
|
- **both lifecycle hooks**, so there is something to see at boot and at shutdown.
|
||||||
|
|
||||||
That is deliberately less than your module will do. What it is *complete* about is
|
That is deliberately less than your module will do. What it is *complete* about is
|
||||||
@@ -28,7 +31,9 @@ module.json what core reads first — id, version, coreApi, moun
|
|||||||
server/
|
server/
|
||||||
index.js register(ctx, api) — the entire server-side handshake
|
index.js register(ctx, api) — the entire server-side handshake
|
||||||
core.js the lazy accessors over ctx; read this second
|
core.js the lazy accessors over ctx; read this second
|
||||||
boot.js onBoot / onShutdown
|
boot.js onBoot / onShutdown, and the game-restart watch
|
||||||
|
sidecarClient.js the one file that talks to your sidecar — transport simulated
|
||||||
|
config/eventActions.js budgets, option sources, leases and actions — read chapter 5
|
||||||
db/schema.sql idempotent, replayed every boot
|
db/schema.sql idempotent, replayed every boot
|
||||||
db/purge.sql destructive, run only by an explicit admin purge
|
db/purge.sql destructive, run only by an explicit admin purge
|
||||||
model/worldStatus/ the .db.js / .model.js pair
|
model/worldStatus/ the .db.js / .model.js pair
|
||||||
@@ -38,6 +43,7 @@ server/
|
|||||||
scripts/checkImports.js the module boundary, enforced
|
scripts/checkImports.js the module boundary, enforced
|
||||||
scripts/swaggerFragment.js generates swagger-fragment.json from your own routes
|
scripts/swaggerFragment.js generates swagger-fragment.json from your own routes
|
||||||
test/ the suites — start with entry.test.js
|
test/ the suites — start with entry.test.js
|
||||||
|
test/eventActions.test.js the four traps chapter 5 is about, each as a failing test
|
||||||
client/
|
client/
|
||||||
vite.config.js the library build: anchored aliases, external: []
|
vite.config.js the library build: anchored aliases, external: []
|
||||||
src/entry.jsx registers routes, nav and declared slots at evaluation time
|
src/entry.jsx registers routes, nav and declared slots at evaluation time
|
||||||
@@ -113,6 +119,7 @@ backticking table names**.
|
|||||||
| `server/package.json` | package `name` and `description` |
|
| `server/package.json` | package `name` and `description` |
|
||||||
| `server/core.js` | the message every accessor throws |
|
| `server/core.js` | the message every accessor throws |
|
||||||
| `server/index.js` | the trigger, audience, template and rule-group ids — all four are namespaced with your module id, and core refuses them otherwise |
|
| `server/index.js` | the trigger, audience, template and rule-group ids — all four are namespaced with your module id, and core refuses them otherwise |
|
||||||
|
| `server/config/eventActions.js` | the budget, option-source, lease and action ids — four separate id spaces, each namespaced with your module id — and every command name the client sends |
|
||||||
| `server/boot.js` | the placeholder world name |
|
| `server/boot.js` | the placeholder world name |
|
||||||
| `server/db/schema.sql` | every table name — the prefix must be your id |
|
| `server/db/schema.sql` | every table name — the prefix must be your id |
|
||||||
| `server/db/purge.sql` | the same table names |
|
| `server/db/purge.sql` | the same table names |
|
||||||
@@ -125,6 +132,7 @@ backticking table names**.
|
|||||||
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
|
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
|
||||||
| `server/test/_fakes.js` | `ctx.moduleId` |
|
| `server/test/_fakes.js` | `ctx.moduleId` |
|
||||||
| `server/test/entry.test.js` | the trigger id the world-status test asserts |
|
| `server/test/entry.test.js` | the trigger id the world-status test asserts |
|
||||||
|
| `server/test/eventActions.test.js` | the action and lease ids it looks up, and the clan fixture |
|
||||||
| `server/test/worldStatus.test.js` | the fixture's world name |
|
| `server/test/worldStatus.test.js` | the fixture's world name |
|
||||||
| `server/test/clanProvider.test.js` | the fixture's world name |
|
| `server/test/clanProvider.test.js` | the fixture's world name |
|
||||||
| `server/package-lock.json` | **regenerated** — `npm install --prefix server` |
|
| `server/package-lock.json` | **regenerated** — `npm install --prefix server` |
|
||||||
@@ -147,6 +155,13 @@ placeholder and is not listed fails the build, and so does a listed file with
|
|||||||
nothing left to rename. A checklist nobody verifies is a checklist that is wrong
|
nothing left to rename. A checklist nobody verifies is a checklist that is wrong
|
||||||
by the second edit.
|
by the second edit.
|
||||||
|
|
||||||
|
**`server/sidecarClient.js` is not on that list and is not an oversight.** It
|
||||||
|
carries no placeholder id — its vocabulary is the game's, not the module's — so
|
||||||
|
the checker has nothing to hold it to. It is still the file you have the most work
|
||||||
|
in: replace `deliver()` with one request to your sidecar, replace the fake game's
|
||||||
|
verbs with your game's, and set `TIMEOUT_MS` to what your transport actually
|
||||||
|
waits. Chapter 5 is mostly about that file.
|
||||||
|
|
||||||
Two things you do **not** rename: the mount prefixes `/world` and `/clans` need
|
Two things you do **not** rename: the mount prefixes `/world` and `/clans` need
|
||||||
not be your id (the server's prefix namespace is shared with core's — `/status`,
|
not be your id (the server's prefix namespace is shared with core's — `/status`,
|
||||||
`/settings`, `/version`, `/contact` and `/teams` are already taken, which is why
|
`/settings`, `/version`, `/contact` and `/teams` are already taken, which is why
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "examplegame",
|
"id": "examplegame",
|
||||||
"name": "Example Game",
|
"name": "Example Game",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"coreApi": "^1.9.0",
|
"coreApi": "^1.10.0",
|
||||||
"server": "server/index.js",
|
"server": "server/index.js",
|
||||||
"client": { "entry": "client/dist/entry.js" },
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
"schema": "server/db/schema.sql",
|
"schema": "server/db/schema.sql",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const core = require('./core')
|
|||||||
|
|
||||||
const worldStatusDb = require('./model/worldStatus/worldStatus.db')
|
const worldStatusDb = require('./model/worldStatus/worldStatus.db')
|
||||||
const clanDb = require('./model/clans/clanProvider.db')
|
const clanDb = require('./model/clans/clanProvider.db')
|
||||||
|
const sidecar = require('./sidecarClient')
|
||||||
|
|
||||||
const log = core.logger('boot')
|
const log = core.logger('boot')
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ const log = core.logger('boot')
|
|||||||
// so that there is something for the shutdown hook to actually do.
|
// so that there is something for the shutdown hook to actually do.
|
||||||
let refreshTimer = null
|
let refreshTimer = null
|
||||||
|
|
||||||
|
// The last boot id the game reported. `null` means "never observed", which is not
|
||||||
|
// the same as "changed" — see `checkForRestart`.
|
||||||
|
let lastBootId = null
|
||||||
|
|
||||||
const REFRESH_MS = 30 * 1000
|
const REFRESH_MS = 30 * 1000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +75,9 @@ async function refresh() {
|
|||||||
const previous = await worldStatusDb.getStatus()
|
const previous = await worldStatusDb.getStatus()
|
||||||
await worldStatusDb.setStatus(next)
|
await worldStatusDb.setStatus(next)
|
||||||
|
|
||||||
|
// Same poll, different question: did the thing we lit beacons in restart?
|
||||||
|
checkForRestart()
|
||||||
|
|
||||||
// `previous === null` is the first boot on a fresh install, not a change.
|
// `previous === null` is the first boot on a fresh install, not a change.
|
||||||
// Treating it as one would announce the world coming online to everyone the
|
// Treating it as one would announce the world coming online to everyone the
|
||||||
// first time an operator started the site.
|
// first time an operator started the site.
|
||||||
@@ -96,6 +104,42 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notice that the game restarted, and tell core.
|
||||||
|
*
|
||||||
|
* **Core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
|
||||||
|
* from a dispatch and cannot tell a wedged sidecar from a game that rebooted and
|
||||||
|
* lost every beacon an event lit. Only this module knows, because only this
|
||||||
|
* module watches the feed the boot id arrives on — which is also how you tell a
|
||||||
|
* game restart from a sidecar reconnect, and they are not the same event: the
|
||||||
|
* second loses nothing.
|
||||||
|
*
|
||||||
|
* So core asks once, at its own boot — the one reconnect it can see — and
|
||||||
|
* otherwise waits to be told. `core.reconcileEvents()` is being told. It returns
|
||||||
|
* at once and core sweeps its resource ledger on its own time, putting the
|
||||||
|
* question back to this module as `reconcile({ runId, resources })` in
|
||||||
|
* `config/eventActions.js`.
|
||||||
|
*
|
||||||
|
* Called from the same poll as everything else here, because a boot id is just
|
||||||
|
* another thing the feed carries. In a real module this is a frame handler rather
|
||||||
|
* than a comparison against a remembered value.
|
||||||
|
*/
|
||||||
|
function checkForRestart() {
|
||||||
|
const bootId = sidecar.currentBootId()
|
||||||
|
if (lastBootId === null) {
|
||||||
|
// First observation is not a restart. Recording it as one would ask core to
|
||||||
|
// reconcile every ledgered resource on every website deploy, which is a sweep
|
||||||
|
// that costs a round trip per action for no news.
|
||||||
|
lastBootId = bootId
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (bootId === lastBootId) return
|
||||||
|
|
||||||
|
lastBootId = bootId
|
||||||
|
log.info('game restarted, asking core to reconcile', { bootId })
|
||||||
|
core.reconcileEvents()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two clans, so that the Team provider has something to be authoritative about.
|
* Two clans, so that the Team provider has something to be authoritative about.
|
||||||
*
|
*
|
||||||
@@ -162,7 +206,8 @@ async function onBoot() {
|
|||||||
async function onShutdown() {
|
async function onShutdown() {
|
||||||
if (refreshTimer) clearInterval(refreshTimer)
|
if (refreshTimer) clearInterval(refreshTimer)
|
||||||
refreshTimer = null
|
refreshTimer = null
|
||||||
|
lastBootId = null
|
||||||
log.info('shut down')
|
log.info('shut down')
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { onBoot, onShutdown, refresh, seedClans, REFRESH_MS }
|
module.exports = { onBoot, onShutdown, refresh, seedClans, checkForRestart, REFRESH_MS }
|
||||||
|
|||||||
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 }
|
||||||
@@ -106,6 +106,23 @@ module.exports = {
|
|||||||
// operator's log six weeks later.
|
// operator's log six weeks later.
|
||||||
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope),
|
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope),
|
||||||
|
|
||||||
|
// Telling core the game restarted (MODULE_API.md §2.3, 1.10.0). The one thing
|
||||||
|
// the event contract adds to `ctx`, and it is here for a reason worth carrying:
|
||||||
|
// **core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
|
||||||
|
// and cannot tell a wedged sidecar from a shard that rebooted and lost every
|
||||||
|
// creature an event spawned. Only this module knows, because only this module
|
||||||
|
// watches the feed the boot id arrives on.
|
||||||
|
//
|
||||||
|
// Calling it asks core to sweep its resource ledger and put the question back
|
||||||
|
// to this module's actions, as `reconcile({ runId, resources })`. Fire and
|
||||||
|
// forget: it returns at once and the sweep happens on core's own time.
|
||||||
|
//
|
||||||
|
// See `boot.js` for the watch that calls it, and `config/eventActions.js` for
|
||||||
|
// the answer. Named longer than the `ctx` member it wraps because this object
|
||||||
|
// is flat — `core.emit` is already a little ambiguous and `core.reconcile()`
|
||||||
|
// would be worse, since a module has more than one thing it could reconcile.
|
||||||
|
reconcileEvents: () => need().events.reconcile(),
|
||||||
|
|
||||||
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
||||||
// only correct way to find a file you shipped, because the working directory is
|
// only correct way to find a file you shipped, because the working directory is
|
||||||
// core's and the module's location is the loader's business.
|
// core's and the module's location is the loader's business.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ module.exports = function register(ctx, api) {
|
|||||||
const worldRouter = require('./router/public/world.router')
|
const worldRouter = require('./router/public/world.router')
|
||||||
const clansRouter = require('./router/public/clans.router')
|
const clansRouter = require('./router/public/clans.router')
|
||||||
const clanProvider = require('./model/clans/clanProvider.model')
|
const clanProvider = require('./model/clans/clanProvider.model')
|
||||||
|
const eventActions = require('./config/eventActions')
|
||||||
const boot = require('./boot')
|
const boot = require('./boot')
|
||||||
/* eslint-enable global-require */
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
@@ -262,6 +263,31 @@ module.exports = function register(ctx, api) {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Events: what a scheduled event may do to your game ───────────────────
|
||||||
|
//
|
||||||
|
// MODULE_API 1.10.0, `EVENTS.md` §F, and chapter 5 of this kit. Four
|
||||||
|
// declarations, and the whole of the file they come from is about the four
|
||||||
|
// rules that are invisible until an outage.
|
||||||
|
//
|
||||||
|
// **This is core CALLING YOU**, like the Team provider above and unlike
|
||||||
|
// everything else in this function — but from further away than either, because
|
||||||
|
// the thing on the other end is a game server. That distance is the reason an
|
||||||
|
// action declares `budgetMs` and the reason its failure default is a retry.
|
||||||
|
//
|
||||||
|
// **Every one of the four is optional.** A module that registers none of them
|
||||||
|
// leaves its deployment with an event engine that can announce, wait, cue a
|
||||||
|
// human and publish results, which is a working product. Each one *adds* what
|
||||||
|
// an author can reach for; none is load-bearing for the engine.
|
||||||
|
//
|
||||||
|
// Registered in this order because it is the order they depend on each other:
|
||||||
|
// an action's `cost` may only name a budget some module declared, and a param's
|
||||||
|
// `source` names an option source. Core resolves both after every module has
|
||||||
|
// registered, so the order here is for a reader rather than for the loader.
|
||||||
|
api.registerEventBudgets(eventActions.BUDGETS)
|
||||||
|
api.registerEventOptionSources(eventActions.OPTION_SOURCES)
|
||||||
|
api.registerEventLeases(eventActions.LEASES)
|
||||||
|
api.registerEventActions(eventActions.ACTIONS)
|
||||||
|
|
||||||
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
||||||
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
|
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
|
||||||
// that must not serve traffic until it has warmed a cache gets that for free.
|
// that must not serve traffic until it has warmed a cache gets that for free.
|
||||||
|
|||||||
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 }
|
||||||
@@ -53,7 +53,11 @@ function fakeCtx(overrides = {}) {
|
|||||||
// whole of what a module may do with it: fire a declared event and stop.
|
// whole of what a module may do with it: fire a declared event and stop.
|
||||||
// Core's own emit is fire-and-forget and returns nothing, so this does too —
|
// Core's own emit is fire-and-forget and returns nothing, so this does too —
|
||||||
// a fake that returned a receipt would invite a module to wait on one.
|
// a fake that returned a receipt would invite a module to wait on one.
|
||||||
events: { emit: spy(undefined) },
|
// `reconcile` joined it at 1.10.0 — the ONE thing the event contract adds to
|
||||||
|
// `ctx`, because an action is called BY core and is handed what it needs in
|
||||||
|
// the envelope. Only the module knows when the game restarted, so only the
|
||||||
|
// module can ask for the sweep.
|
||||||
|
events: { emit: spy(undefined), reconcile: spy(undefined) },
|
||||||
middleware: {
|
middleware: {
|
||||||
requireAuth: (req, res, next) => next(),
|
requireAuth: (req, res, next) => next(),
|
||||||
requireRole: () => (req, res, next) => next(),
|
requireRole: () => (req, res, next) => next(),
|
||||||
@@ -93,6 +97,7 @@ function fakeApi() {
|
|||||||
const record = {
|
const record = {
|
||||||
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
|
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
|
||||||
triggers: null, audiences: null, engagementSeeds: null,
|
triggers: null, audiences: null, engagementSeeds: null,
|
||||||
|
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
|
||||||
}
|
}
|
||||||
const called = new Set()
|
const called = new Set()
|
||||||
const once = (name) => {
|
const once = (name) => {
|
||||||
@@ -114,6 +119,13 @@ function fakeApi() {
|
|||||||
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
||||||
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
||||||
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
||||||
|
// The event contract (1.10.0). `once` on all four: a batch is a module's
|
||||||
|
// COMPLETE statement about what it declares, so a second call is a module
|
||||||
|
// changing its mind halfway through `register()` rather than adding to it.
|
||||||
|
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
||||||
|
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||||
|
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||||
|
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,3 +290,64 @@ test('the world event fires on the transition and not on the poll', async () =>
|
|||||||
await boot.refresh()
|
await boot.refresh()
|
||||||
assert.deepStrictEqual(fresh.events.emit.calls, [])
|
assert.deepStrictEqual(fresh.events.emit.calls, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the four event declarations are registered, each exactly once', () => {
|
||||||
|
const { api } = register()
|
||||||
|
|
||||||
|
// Every one of the four is optional (§F), so this asserts what THIS module
|
||||||
|
// chose rather than what core requires. What it is really checking is that
|
||||||
|
// `index.js` still hands core the arrays `config/eventActions.js` exports —
|
||||||
|
// the failure it catches is a rename on one side and not the other, which
|
||||||
|
// costs a deployment a capability with nothing red anywhere.
|
||||||
|
assert.ok(Array.isArray(api.record.eventBudgets))
|
||||||
|
assert.ok(Array.isArray(api.record.eventOptionSources))
|
||||||
|
assert.ok(Array.isArray(api.record.eventLeases))
|
||||||
|
assert.ok(Array.isArray(api.record.eventActions))
|
||||||
|
|
||||||
|
// `once` on all four: a batch is a module's COMPLETE statement about what it
|
||||||
|
// declares. `fakeApi` throws on a second call, so registering twice fails here.
|
||||||
|
assert.ok(api.record.eventActions.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an action may only spend a budget dimension some module declared', () => {
|
||||||
|
const { api } = register()
|
||||||
|
|
||||||
|
// Core refuses a `cost()` naming an undeclared dimension at save, at the dry
|
||||||
|
// run and at dispatch, because the fix is a module's declaration rather than a
|
||||||
|
// deployment's cap. This module declares everything it spends, so the check is
|
||||||
|
// local; a module spending another module's dimension would have to loosen it.
|
||||||
|
const declared = new Set(api.record.eventBudgets.map((b) => b.id))
|
||||||
|
for (const action of api.record.eventActions) {
|
||||||
|
const sample = Object.fromEntries(action.params.map((p) => [p.name, p.example]))
|
||||||
|
for (const dimension of Object.keys(action.cost(sample))) {
|
||||||
|
assert.ok(declared.has(dimension), `${action.id} spends undeclared ${dimension}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a game restart asks core to reconcile, and a first sighting does not', () => {
|
||||||
|
const boot = require('../boot')
|
||||||
|
const sidecar = require('../sidecarClient')
|
||||||
|
|
||||||
|
const ctx = fakeCtx()
|
||||||
|
require('../core')._reset()
|
||||||
|
require('../core').init(ctx)
|
||||||
|
|
||||||
|
// First observation is not a restart. Treating it as one would sweep every
|
||||||
|
// ledgered resource on every website deploy, for no news.
|
||||||
|
boot.checkForRestart()
|
||||||
|
assert.deepStrictEqual(ctx.events.reconcile.calls, [])
|
||||||
|
|
||||||
|
// Same boot id: still nothing.
|
||||||
|
boot.checkForRestart()
|
||||||
|
assert.deepStrictEqual(ctx.events.reconcile.calls, [])
|
||||||
|
|
||||||
|
// The game came back as something else. Core cannot see this and must be told.
|
||||||
|
sidecar.simulateRestart()
|
||||||
|
boot.checkForRestart()
|
||||||
|
assert.strictEqual(ctx.events.reconcile.calls.length, 1)
|
||||||
|
|
||||||
|
// And only once for one restart.
|
||||||
|
boot.checkForRestart()
|
||||||
|
assert.strictEqual(ctx.events.reconcile.calls.length, 1)
|
||||||
|
})
|
||||||
|
|||||||
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