Merge pull request 'feat(kit): the event contract, taught and built (Phase 15)' (#10) from feature/events-p15-event-contract into main
Reviewed-on: #10
This commit is contained in:
@@ -57,7 +57,10 @@ typing commands can only be polled, and polling turns "someone left at 14:02" in
|
||||
the contract cannot do yet.
|
||||
2. **`template/`** — a module that builds and loads, doing almost nothing. Copy it,
|
||||
rename it, and you have a running module before you have read a chapter.
|
||||
3. **The book** — [`book/`](book/), four chapters, in the order the work happens.
|
||||
3. **The book** — [`book/`](book/), five chapters, in the order the work happens.
|
||||
The first four are the job. The fifth is optional and comes after you have a
|
||||
working module: what to declare if you want a scheduled event on the website to
|
||||
be able to change your live world, and get it back afterwards.
|
||||
|
||||
## The one rule this kit follows
|
||||
|
||||
@@ -69,6 +72,7 @@ kit and one of them disagree, they win and the kit has a bug:
|
||||
| [`MODULE_API.md`][api] | Everything a module may do: `module.json`, `ctx`, the `register*` calls, the client registry, the UI kit, schema-fragment rules, the loader's obligations. |
|
||||
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
||||
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The shard↔sidecar wire protocol, as one real sidecar implements it. |
|
||||
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, what core owns, and every rule chapter 5 explains the reasoning behind. |
|
||||
|
||||
The kit *teaches*: the order to do things in, the reasoning, worked examples, and
|
||||
the mistakes that cost this project time. Where it must show a member list it
|
||||
@@ -104,6 +108,7 @@ same licence, and so does anything derived from it.
|
||||
[module-uo]: https://gitea.whitlocktech.com/RunicGateway/Module-uo
|
||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
||||
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
|
||||
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||
|
||||
@@ -89,6 +89,52 @@ A module cannot do any of this from inside the website process. There is nowhere
|
||||
put what arrives while the website is not running, because the website not running
|
||||
is exactly the case.
|
||||
|
||||
## 2a. The other direction, if you ever want events
|
||||
|
||||
Everything above is about data leaving the game. Skip this section until you want
|
||||
[chapter 5](05-events.md) — but read it *before* you build the sidecar rather than
|
||||
after, because retrofitting it is more work than allowing for it.
|
||||
|
||||
An event on the website is core telling your module *"do this to the world now"*,
|
||||
and your module telling your sidecar, and your sidecar telling the game. That is a
|
||||
**command** — a request with a reply, going the way nothing above goes. It needs
|
||||
three things the read path does not.
|
||||
|
||||
**Request/reply correlation.** A command is not a broadcast: the caller waits for
|
||||
an answer and has to know which answer is theirs. `uo-link` does this in
|
||||
`sidecar/src/rpc.rs` — an id on the way out, a map of pending calls, the reply
|
||||
matched back and the waiter woken. You need it for reads that ask the game a live
|
||||
question too, so it is often already there; commands are what make it load-bearing.
|
||||
|
||||
**An idempotency key, executed at most once, stored where the game is.** Core hands
|
||||
your module a key that is a function of the step's identity and never of the
|
||||
attempt, so every retry carries the same one. The far end must execute a given key
|
||||
once and answer a repeat with **the reply the first attempt produced** — not by
|
||||
running the command again.
|
||||
|
||||
That store belongs as close to the game as the state it protects. A store in the
|
||||
sidecar is right for a command whose effect is the sidecar's own; a command that
|
||||
changes the *world* needs the store where the world is, because the case it exists
|
||||
for is the game restarting mid-run. And a repeat arriving while the original is
|
||||
still in flight is its own answer — "busy", transient by construction, because the
|
||||
work is happening.
|
||||
|
||||
Without this, a command that arrived, ran, and whose acknowledgement was lost is
|
||||
indistinguishable from one that never arrived. The only safe policy is then never
|
||||
to retry, which means a game restarting mid-event writes the step off.
|
||||
|
||||
**A deadline the game enforces on its own.** A borrowed value — a doubled gather
|
||||
rate, a raised spawn cap — carries an expiry down the wire, and the game side must
|
||||
restore the baseline when it passes **without being asked again**. The website's
|
||||
copy of that deadline is for the console. The game's copy is the fail-safe: if the
|
||||
website is never heard from again, the value still comes back.
|
||||
|
||||
Two details that are easy to get wrong and expensive to change later. Send the
|
||||
deadline as a **duration**, not an absolute time — two machines' clocks are two
|
||||
clocks. And if the borrowed value lives in the game's own save file, the *hold*
|
||||
must be persisted and the timer re-armed at load; a restart preserves the change
|
||||
and destroys only the thing that would have undone it.
|
||||
|
||||
## 3. The wire is a versioned contract, not a build dependency
|
||||
|
||||
Your sidecar and your module ship separately, on different schedules, to hosts you
|
||||
|
||||
@@ -103,6 +103,53 @@ escape into a game code path. `BridgeLink` wraps the inbound handler and logs
|
||||
anything it throws, because the alternative is an exception unwinding somewhere in
|
||||
the engine's main loop.
|
||||
|
||||
## A command that changes the world runs at most once
|
||||
|
||||
Skip this until you want [chapter 5](05-events.md). Everything above assumes an
|
||||
inbound line either asks a question or is a one-off an operator typed. An **event**
|
||||
is neither: it is unattended, it is retried, and what it does is permanent.
|
||||
|
||||
Three obligations, and they all live on this side of the wire because this is the
|
||||
side that has the world.
|
||||
|
||||
**Keep a key store, and persist it.** Every command an event sends carries an
|
||||
idempotency key — a function of the step's identity, never of the attempt, so a
|
||||
retry carries the one the first attempt did. Before executing, look the key up:
|
||||
|
||||
- **not seen** — execute, then record the key *with the reply you are about to
|
||||
send*;
|
||||
- **seen and finished** — send that stored reply back, unchanged. Do not re-run;
|
||||
- **seen and still running** — answer "busy". It is transient by construction, and
|
||||
the caller will retry; running it concurrently with itself is the failure.
|
||||
|
||||
The stored reply matters as much as the guard. A repeat that re-ran and returned a
|
||||
*new* serial would be two things in the world and one in the website's ledger,
|
||||
which is the exact failure the key exists to prevent, arrived at by a longer route.
|
||||
|
||||
**Persist it in the world save, not in memory**, if what the command creates
|
||||
survives a restart. The case this whole mechanism exists for is a game restarting
|
||||
mid-event, and a key store that dies with the process is a store that is empty in
|
||||
precisely that case.
|
||||
|
||||
**Own what an event made, and expire what it borrowed.**
|
||||
|
||||
An event-created thing has to be findable again later, because the website will ask
|
||||
you to remove it after the event and may ask more than once. That means a registry
|
||||
— a persisted map from the website's reference to the object — and it means
|
||||
`remove` is idempotent: **removing something that is not there is a success.** The
|
||||
website records a resource *before* it is confirmed, so it will ask you about
|
||||
things that may never have existed, and neither end can tell the difference.
|
||||
|
||||
A borrowed value is the mirror. It arrives with a duration, and you arm a timer that
|
||||
puts the baseline back when it expires. If the borrowed value lives in the save
|
||||
file, persist the hold and **re-arm the timer at load** — a restart preserves the
|
||||
change and destroys only the thing that would have undone it. Restore with a
|
||||
compare-and-set against what you applied: if a staff member has moved it by hand
|
||||
since, report that rather than overwriting them.
|
||||
|
||||
The through-line: **the game enforces the expiry, not the website.** If the website
|
||||
is never heard from again, every borrowed value still comes back on its own.
|
||||
|
||||
## Reconnect, and what to send on connect
|
||||
|
||||
Your sidecar restarts independently of your game. It comes back with an empty
|
||||
@@ -173,12 +220,25 @@ Two practical notes from that file, both general:
|
||||
If all eight hold, the worst a broken sidecar can do to your game is nothing at
|
||||
all — which is the entire point of the arrangement.
|
||||
|
||||
Three more, and only if you took commands (chapter 5):
|
||||
|
||||
9. A command's idempotency key is looked up before it is executed, and a repeat is
|
||||
answered with the stored reply rather than re-run.
|
||||
10. What an event made is in a persisted registry, and removing something absent is
|
||||
a success.
|
||||
11. A borrowed value's expiry is armed by this side, re-armed at load, and restored
|
||||
with a compare-and-set.
|
||||
|
||||
---
|
||||
|
||||
That is the book. The three parts are a module core loads, a sidecar that owns the
|
||||
game connection and the durable copy of what it said, and a plugin that feeds the
|
||||
sidecar without ever waiting on it.
|
||||
|
||||
[Chapter 5](05-events.md) is the optional fifth part: what to declare if you want
|
||||
the website to be able to change your world on a schedule, and the four mistakes
|
||||
that make that unsafe.
|
||||
|
||||
If you got this far and built something, the places you got stuck are the most
|
||||
valuable thing this repo can receive — [tell us][issues], and please say where you
|
||||
left the kit and what you did next.
|
||||
|
||||
364
book/05-events.md
Normal file
364
book/05-events.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# 5. Making your module event-capable
|
||||
|
||||
Chapters 1 to 4 got a game onto the platform: a module that reads, a sidecar that
|
||||
stores, a plugin that tells it what happened. Everything in them moves one way —
|
||||
out of the game and onto a page.
|
||||
|
||||
This chapter is about the other direction. The event system is core's engine for
|
||||
**scheduled, bounded, audited changes to a live game world**: an operator writes an
|
||||
event on the website — a phase that announces, a phase that spawns something, a
|
||||
phase that waits for a condition, a phase that cleans up — publishes it, schedules
|
||||
it, and it runs unattended at two in the morning. Your module is what lets any of
|
||||
that touch your game.
|
||||
|
||||
It is also the first thing in this book that can do damage. A page that renders
|
||||
wrong is embarrassing. An action that half-ran and was recorded as done is a
|
||||
change to a live world with nothing coming back for it.
|
||||
|
||||
Nothing here is normative. [`EVENTS.md`][events] is the design of record and
|
||||
[`MODULE_API.md`][api] is the contract; where this chapter and either of those
|
||||
disagree, they are right and this chapter has a bug. What is here is the ordering,
|
||||
the reasoning, and the four mistakes that are invisible until an outage.
|
||||
|
||||
---
|
||||
|
||||
## Everything in this chapter is optional
|
||||
|
||||
Stated first because it changes how you should read the rest.
|
||||
|
||||
A deployment with **no module at all** still has a working event engine. Core owns
|
||||
verbs of its own — announce something, wait, cue a human to do the in-game part,
|
||||
publish results — and an event composed only of those runs on bare core with zero
|
||||
modules installed. That is not a degraded mode; it is a real product, and for many
|
||||
games it is the whole of what you want.
|
||||
|
||||
So each of the four declarations below *adds* something an author can reach for.
|
||||
Registering none of them costs your deployment a capability, never a boot — the
|
||||
same posture as a module with no `onBoot`, which still reaches `started`.
|
||||
|
||||
Which means you can stop reading at any section boundary and ship what you have.
|
||||
|
||||
## The four declarations
|
||||
|
||||
```js
|
||||
api.registerEventBudgets([...]) // dimensions core can COUNT and BOUND
|
||||
api.registerEventOptionSources([...]) // what a dropdown on the form is FILLED from
|
||||
api.registerEventLeases([...]) // values a run may BORROW, with a deadline
|
||||
api.registerEventActions([...]) // verbs a run may PERFORM
|
||||
```
|
||||
|
||||
Four separate id spaces, each namespaced under your module id. `examplegame.beacons`
|
||||
as a budget and `examplegame.beacon.light` as an action are not a collision, and
|
||||
reading them as one would forbid the most natural set of names you will ever
|
||||
write. An action names a VERB, a budget a RESOURCE, a lease a VALUE, an option
|
||||
source a CATALOG.
|
||||
|
||||
All four are in the template at
|
||||
[`template/server/config/eventActions.js`](../template/server/config/eventActions.js),
|
||||
one of each, with the four traps marked where they bite. Read that file beside
|
||||
this chapter.
|
||||
|
||||
## Build the lease first
|
||||
|
||||
If you have time for one thing, build a lease, not an action. This is the kit
|
||||
disagreeing with the obvious priority on purpose.
|
||||
|
||||
The obvious thing to build is spawning: an event that puts creatures at a landmark
|
||||
is what a game event *looks* like. But spawning is a shape one genre happens to
|
||||
have, and it is the harder half — something now exists that did not, and your
|
||||
module owes core a way to take it away again on every terminal path, including
|
||||
the ones where nobody is watching.
|
||||
|
||||
A lease is the other shape: **a value that already existed, changed for a while,
|
||||
and put back.** "Double the gather rate for the weekend." "Turn the night length
|
||||
down until Sunday." "Raise this spawner's population for the invasion." That is
|
||||
the canonical community event in most games, and it is cheaper to make safe,
|
||||
because the value you are replacing already exists and reading it first gives you
|
||||
your baseline for nothing.
|
||||
|
||||
**The verb is core's, not yours.** You declare what can be held and how long; an
|
||||
author puts `core.lease` in a step naming your lease, a value and a number of
|
||||
minutes, and core reads the baseline, reserves the target, applies the value with
|
||||
a deadline, and restores it at teardown through your own `restore()`. A lease verb
|
||||
of your own would be that duration bound and that "two events cannot hold one
|
||||
target" check re-implemented once per module — advisory everywhere, and wrong in
|
||||
the first one that forgot it.
|
||||
|
||||
```js
|
||||
api.registerEventLeases([{
|
||||
id: 'examplegame.rate.gather',
|
||||
label: 'Gather rate',
|
||||
type: 'float', min: 0.5, max: 5,
|
||||
maxDurationMs: 48 * 60 * 60 * 1000,
|
||||
|
||||
async read() { /* the live baseline */ },
|
||||
async apply(value, until) { /* hold it, and send `until` down the wire */ },
|
||||
async restore(baseline, { expected }) { /* put it back, or report drift */ },
|
||||
async inForce() { /* optional — a FOURTH question, see below */ },
|
||||
}])
|
||||
```
|
||||
|
||||
Three things about that shape are worth more than their size.
|
||||
|
||||
**`until` goes down the wire and the far end honours it without being asked
|
||||
again.** Core's copy of the deadline is for the console; the game's copy is the
|
||||
fail-safe. A module that passes `until` and then relies on core coming back to
|
||||
restore has built a lease that outlives an outage — which is the one thing a lease
|
||||
exists to prevent. If the website is never heard from again, the value must still
|
||||
come back.
|
||||
|
||||
**`restore()` reports drift rather than overwriting it.** `expected` is what core
|
||||
believes is applied. If the live value differs, somebody moved it by hand during
|
||||
your event, and answering `{ ok: true, drifted: true, value }` lands the row as
|
||||
`drifted` with the current value beside it. Silently restoring over a human's edit
|
||||
is the bug this exists to prevent.
|
||||
|
||||
**`inForce()` is a fourth question, not a fourth spelling of `read()`.** It asks
|
||||
*"does the game side still have any record of this hold?"*, and none of the other
|
||||
three answers it. A value that DIFFERS from what the run applied is drift, which
|
||||
`restore()` reports; a reconcile that inferred absence from a changed value would
|
||||
take the row out and tell an operator the lease vanished rather than that somebody
|
||||
moved it. Optional — and `{ ok: true, held: false }` is the only thing that takes
|
||||
a lease's ledger row out. A throw, a refusal, or no `inForce()` at all leaves the
|
||||
row alone.
|
||||
|
||||
**Only advertise a lease you have verified takes effect.** A value your game reads
|
||||
once at start-up and caches will apply cleanly, read back cleanly, and do nothing
|
||||
at all. Core cannot catch that and neither can review — it is a capability that
|
||||
lies. Apply it, observe it in the running game, restore it. Per key, as a test.
|
||||
The UO module surveyed 156 config reads in its game and found roughly eight that
|
||||
were live; the rest were cached at boot and would all have lied.
|
||||
|
||||
## Actions, and what "owning" something means
|
||||
|
||||
An action is a verb an author puts in a step. What it makes, the run OWNS until
|
||||
teardown.
|
||||
|
||||
```js
|
||||
api.registerEventActions([{
|
||||
id: 'examplegame.beacon.light',
|
||||
label: 'Light beacons',
|
||||
risk: 'change', // notify | inspect | change | irreversible
|
||||
reversible: 'ledger', // none | self | ledger | override
|
||||
version: 1,
|
||||
budgetMs: 15000,
|
||||
cost: (p) => ({ 'examplegame.beacons': p.count }),
|
||||
params: [ /* every one carries an `example` */ ],
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {},
|
||||
async revert({ runId, resources, idempotencyKey }) {}, // required iff 'ledger'
|
||||
async reconcile({ runId, resources }) {}, // optional
|
||||
}])
|
||||
```
|
||||
|
||||
**`reversible: 'ledger'` is a promise.** It says core may record what you made and
|
||||
come back later to have it undone, and it makes `revert` required. Core's cleanup
|
||||
is **derived, not authored**: there is no `on_teardown` field and no cleanup phase
|
||||
in a spec, because an operator cannot be relied on to write the undo and an
|
||||
aborted run never reaches the phase they wrote it in. Cleanup is one sweep over
|
||||
the ledger and it runs on every terminal path — completion, cancellation and abort
|
||||
alike. Your only job is to answer `revert` correctly, however many times you are
|
||||
asked.
|
||||
|
||||
**`verify: true` must change nothing and must answer honestly.** It is the dry
|
||||
run, and it rides the same dispatcher a real run uses — because a dry run down a
|
||||
second code path is a dry run of the second path. Validate everything you can
|
||||
reach without writing, then stop. Answering `{ ok: true }` unconditionally makes
|
||||
the dry run worthless in the one situation it exists for.
|
||||
|
||||
**`example` is required on every param, optional ones included.** It is the
|
||||
authoring form's placeholder. It is one word at declaration time and it is
|
||||
unreconstructable afterwards by anybody who did not write the action.
|
||||
|
||||
**A `source` on a param makes it a dropdown**, filled by an option source you (or
|
||||
another module) registered. A source that refuses degrades its field to free text
|
||||
with a warning and never blocks the form — so resolve from live data and return
|
||||
`[]` on failure, rather than defending with a hardcoded list that will be wrong.
|
||||
|
||||
---
|
||||
|
||||
# The four things that are invisible until an outage
|
||||
|
||||
Everything above is ordinary. These four are the ones that look like they are
|
||||
working, in every test you write and every demo you give, right up until the day
|
||||
something is down.
|
||||
|
||||
## 1. The failure default is a retry, and `budgetMs` is what makes the other half reachable
|
||||
|
||||
**No shape a failure can take reads as success.** A rejected promise, a throw, a
|
||||
budget timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`.
|
||||
`retry` is opted OUT of: a module that means "this will never work" must say
|
||||
`retry: false`.
|
||||
|
||||
That direction is deliberate, and it is `registerTeamProvider`'s default
|
||||
*inverted*. A Team provider that refuses leaves core showing what it had, because
|
||||
staleness is cheap. An action that half-ran and was recorded as done is a world
|
||||
change nothing will ever come back for.
|
||||
|
||||
Now the part that is easy to miss. Core's dispatcher enforces `budgetMs`, and when
|
||||
the budget expires it classifies the failure as **retry, unconditionally, without
|
||||
asking you** — it cannot ask, your action is still awaiting a socket.
|
||||
|
||||
**So if your transport's timeout is longer than `budgetMs`, your own `retry: false`
|
||||
is unreachable code.** Core's default `budgetMs` is 10 seconds. If your sidecar
|
||||
client waits 12, core's deadline fires first on every slow game and the step is
|
||||
retried no matter what your envelope says. The first module this project shipped
|
||||
had exactly that pairing, and its one deliberately un-retryable verb was retried
|
||||
anyway for a whole phase.
|
||||
|
||||
The rule generalises past that one pairing: **an action is the near end of a call
|
||||
with a far end, and the near end has to outlive it.** Derive one constant from the
|
||||
other rather than typing both, and assert the inequality in a test — the template
|
||||
does both, because a number typed twice drifts the first time somebody tunes the
|
||||
client and does not think to look at the other file.
|
||||
|
||||
**The reason a refusal gives goes in `error`.** Core reads exactly `ok`, `retry`
|
||||
and `error` off a failure envelope; a message under any other name is dropped in
|
||||
silence and the operator sees `"<action id> refused"`. Writing this chapter's
|
||||
template is how that was found — its first draft used `detail`, and every refusal
|
||||
it produced was anonymous.
|
||||
|
||||
## 2. Pass the idempotency key through, and put it on a command rather than a question
|
||||
|
||||
Core hands `perform()` an `idempotencyKey` derived from the step's identity — never
|
||||
from the attempt number — so **every retry carries the same one**. The far end,
|
||||
which is the only end that can tell a retry from a repeat, executes a key at most
|
||||
once and answers a repeat with the ORIGINAL reply rather than running it again.
|
||||
|
||||
Pass it through unchanged. A module that invents its own key here, or drops it,
|
||||
has an action that cannot be retried safely, and the cost of that is not a failed
|
||||
step: it is a second set of everything on a socket hiccup. It looks correct in
|
||||
every test you will write, because in every test the first attempt succeeds.
|
||||
|
||||
It is also what a lost acknowledgement is recovered from. Without a key, a command
|
||||
that arrived, ran, and whose reply was lost is indistinguishable from one that
|
||||
never arrived — so the only safe policy is never to retry, and a game restarting
|
||||
mid-run writes the step off. With one, the retry collects the answer the first
|
||||
attempt never delivered.
|
||||
|
||||
**And it belongs on a command, never on a question.** This is the correction
|
||||
writing the template produced, and it is quiet and total: an at-most-once store
|
||||
answers a key it has already seen with the first reply, forever. So a *read* that
|
||||
carries a key returns the first read's value on every subsequent call — the lease
|
||||
applied correctly, the game changed correctly, and the module could no longer see
|
||||
any of it. `read()` reported the pre-run baseline and `inForce()` said nothing was
|
||||
held. The template splits its client into `ask()` and `send()` for exactly this
|
||||
reason.
|
||||
|
||||
The rule for which commands need a key is narrower than "all of them", too. A key
|
||||
is for a write whose repetition would be a second EFFECT — creating, granting,
|
||||
announcing. A write that SETS a value to X is idempotent by its own nature: doing
|
||||
it twice is doing it once, and a key would only pin its reply.
|
||||
|
||||
Build the store on the **far end**, and persist it. A store in your module answers
|
||||
nothing, because the case that matters is the one where the command arrived and
|
||||
ran. See [chapter 4](04-game-plugin.md) for the game-side half.
|
||||
|
||||
## 3. Core records a resource BEFORE it is confirmed
|
||||
|
||||
This is one line in [`EVENTS.md`][events] §D and it decides the whole shape of your
|
||||
`revert`.
|
||||
|
||||
Core writes a placeholder into its ledger, keyed by the step's idempotency key,
|
||||
**before** dispatching — so a dispatch whose answer never came back is still
|
||||
something cleanup can act on. Your `resources` are the refs core did not know until
|
||||
the answer arrived, filled in afterwards.
|
||||
|
||||
Two consequences, and both are about what `revert` must tolerate:
|
||||
|
||||
**Reverting something that does not exist is a SUCCESS.** Cleanup will ask you
|
||||
about rows for things that may never have existed. You must never have to tell
|
||||
"I removed it" from "it was not there" — and you could not, because your game
|
||||
cannot either. Answer `{ ok: true }`. This is also what a game with a monthly wipe
|
||||
needs, where every ledgered resource is invalidated at once and "gone, and that is
|
||||
fine" is the only useful answer.
|
||||
|
||||
**You will be called with NO resources and only a key.** That is the lost-answer
|
||||
case stated exactly: core knows a dispatch went out under this key and never
|
||||
learned what it made. A module that can undo by key answers honestly. One that
|
||||
cannot answers `{ ok: false }`, and the row stays visible to an operator — which is
|
||||
the correct outcome, not a silent one. Answering `{ ok: true }` to a question you
|
||||
cannot answer is how something burns in a live world forever with core's ledger
|
||||
reporting it cleaned up.
|
||||
|
||||
`revert` must also be idempotent, because core may ask more than once.
|
||||
|
||||
**`reconcile` is optional where `revert` is required, and the asymmetry is the
|
||||
design.** A module that cannot say what the game still has is not broken — core
|
||||
keeps believing its own ledger, which is the behaviour before any of this existed.
|
||||
One that created something and cannot undo it has made a promise core has no way
|
||||
to keep.
|
||||
|
||||
And when you do answer: **anything that is not an explicit
|
||||
`{ ok: true, inForce: [...] }` leaves the ledger alone.** "I do not know" is never
|
||||
read as "it is gone". A resource you report missing becomes `orphaned` rather than
|
||||
`reverted`, because nobody asked for it to go.
|
||||
|
||||
**You say WHEN to reconcile, because core cannot.** Core has no concept of the game
|
||||
being up — it sees `{ ok: false, retry: true }` and cannot tell a wedged sidecar
|
||||
from a game that rebooted and lost everything an event made. So it asks once, at
|
||||
its own boot, and otherwise waits to be told. `ctx.events.reconcile()` is being
|
||||
told, and the thing that triggers it is your own watch on a boot id changing — which
|
||||
is also how you tell a game restart from a sidecar reconnect. They are not the
|
||||
same event; the second loses nothing.
|
||||
|
||||
## 4. Under-declaring `cost` turns every cap into a lie
|
||||
|
||||
`cost(params)` says what one invocation consumes. An operator sets caps per
|
||||
dimension, and core refuses a step that would exceed one.
|
||||
|
||||
**Core prices `cost` before dispatch and never reconciles it against the resources
|
||||
that come back.** It cannot — it does not know what a beacon is. So an action that
|
||||
returns `{ 'examplegame.beacons': 1 }` while lighting twelve turns an operator's cap
|
||||
of 30 into a cap of 360, the meter on the run console agrees with the lie, and
|
||||
nothing anywhere goes red. The first symptom is a world with an order of magnitude
|
||||
more in it than anyone authorised.
|
||||
|
||||
Count what you will actually make, from the params you were given, every time. **If
|
||||
you cannot know until the answer comes back, declare the maximum**: a spend that is
|
||||
too high refuses an event that would have fit, which an author can see and argue
|
||||
with; one that is too low cannot be seen at all.
|
||||
|
||||
Two smaller rules ride with it:
|
||||
|
||||
- **You cannot spend a dimension no module declared.** A `cost()` naming an
|
||||
unregistered one is refused at save, at the dry run and at dispatch, with its own
|
||||
refusal code — because the fix is a module's declaration and not a deployment's
|
||||
cap.
|
||||
- **Declaring a dimension is not the same as bounding it.** A declared dimension
|
||||
with no operator cap is counted and unbounded, which is useful on its own: the
|
||||
run console then shows an author what their event actually spent.
|
||||
|
||||
---
|
||||
|
||||
## What core owns that you might think is yours
|
||||
|
||||
Four things a second module's author reaches for and should not.
|
||||
|
||||
| You might build | Core already owns it | Because |
|
||||
| --- | --- | --- |
|
||||
| A `myGame.lease` verb | `core.lease` | the duration bound and the two-events-one-target check belong in one place, or they are advisory everywhere |
|
||||
| A cleanup phase, or `on_teardown` | the ledger sweep | an aborted run never reaches the phase somebody wrote the undo in |
|
||||
| Deciding who is told about your event | rules and audiences | you declare what CAN happen; core decides who is told ([chapter 2](02-website-module.md)) |
|
||||
| A second write path for participants | the `participants` envelope member | a second door into a run core is mid-tick on is a second thing that can race the step claim |
|
||||
|
||||
## What to build, in order
|
||||
|
||||
1. **Nothing.** Confirm an event composed of core's own verbs runs on your
|
||||
deployment. If it does, the engine is working and everything below is additive.
|
||||
2. **One budget dimension**, declared and uncapped. Costs nothing and makes the
|
||||
next step legible.
|
||||
3. **One lease**, verified live — apply, observe in the running game, restore.
|
||||
This is the primitive that travels, and for many games it is the whole feature.
|
||||
4. **One option source**, so the authoring form stops asking operators to type
|
||||
identifiers from memory.
|
||||
5. **One action that ledgers**, with `revert` and the four traps above. This is
|
||||
where the work is, and where the damage is.
|
||||
6. **`reconcile`**, and the boot-id watch that calls `ctx.events.reconcile()`.
|
||||
Last, because it is the only one whose absence is merely a lower standard
|
||||
rather than a broken promise.
|
||||
|
||||
Then read your own `revert` again, and ask what it answers when the game is down.
|
||||
|
||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
|
||||
@@ -1,6 +1,7 @@
|
||||
# The book
|
||||
|
||||
Four chapters, in the order the work happens.
|
||||
Five chapters, in the order the work happens. The first four are the job; the
|
||||
fifth is optional and comes after you have one.
|
||||
|
||||
Read [the dry run][dryrun] before any of them — a complete module designed on
|
||||
paper for a second game, and the shortest honest picture of the whole job.
|
||||
@@ -11,12 +12,20 @@ paper for a second game, and the shortest honest picture of the whole job.
|
||||
| 2 | [The website module](02-website-module.md) | The bulk of the work: `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do. |
|
||||
| 3 | [The sidecar](03-sidecar.md) | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is. |
|
||||
| 4 | [The game-side plugin](04-game-plugin.md) | The least code and the highest stakes: never block the game thread. |
|
||||
| 5 | [Making your module event-capable](05-events.md) | Optional, and the first thing here that can do damage: letting a scheduled event on the website change your live world, and get it back. |
|
||||
|
||||
Chapters 1 and 2 quote `template/`, which CI builds against a pinned core, so their
|
||||
code is a tree that is proved rather than prose that looks like one. Chapters 3 and
|
||||
4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by line, on
|
||||
purpose: those repositories move for their own reasons and a line number in a book
|
||||
is wrong the moment they do.
|
||||
Chapters 1, 2 and 5 quote `template/`, which CI builds against a pinned core, so
|
||||
their code is a tree that is proved rather than prose that looks like one. Chapters
|
||||
3 and 4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by
|
||||
line, on purpose: those repositories move for their own reasons and a line number in
|
||||
a book is wrong the moment they do.
|
||||
|
||||
**Chapter 5 is the one you can stop before.** Chapters 1 to 4 get a game onto the
|
||||
platform and everything in them moves one way — out of the game and onto a page.
|
||||
Chapter 5 is the other direction, and a deployment that never reads it still has a
|
||||
working event engine over core's own verbs. Chapters 3 and 4 each carry one section
|
||||
that only matters if you are going there (§2a and *"A command that changes the
|
||||
world runs at most once"*); both say so at the top.
|
||||
|
||||
## What is normative, and what is here
|
||||
|
||||
@@ -28,12 +37,14 @@ document is right and the chapter has a bug — [say so][issues]:
|
||||
| [`MODULE_API.md`][api] | Everything a module may do. |
|
||||
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
||||
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The game↔sidecar wire protocol, as one real sidecar implements it. |
|
||||
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, and what core owns. |
|
||||
|
||||
The chapters teach: the order to do things in, the reasoning, and the mistakes that
|
||||
cost this project time.
|
||||
|
||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
||||
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
|
||||
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||
|
||||
@@ -15,6 +15,9 @@ Installed into a core, it adds:
|
||||
for the deployment — the one registration where core calls YOU and waits;
|
||||
- **three inverted extension slots**, declared by this module on the clan page for
|
||||
core to fill;
|
||||
- **four event declarations** — a budget dimension, an option source, a lease and
|
||||
one action that ledgers what it makes — so an event authored on the website can
|
||||
reach the game and be undone afterwards;
|
||||
- **both lifecycle hooks**, so there is something to see at boot and at shutdown.
|
||||
|
||||
That is deliberately less than your module will do. What it is *complete* about is
|
||||
@@ -28,7 +31,9 @@ module.json what core reads first — id, version, coreApi, moun
|
||||
server/
|
||||
index.js register(ctx, api) — the entire server-side handshake
|
||||
core.js the lazy accessors over ctx; read this second
|
||||
boot.js onBoot / onShutdown
|
||||
boot.js onBoot / onShutdown, and the game-restart watch
|
||||
sidecarClient.js the one file that talks to your sidecar — transport simulated
|
||||
config/eventActions.js budgets, option sources, leases and actions — read chapter 5
|
||||
db/schema.sql idempotent, replayed every boot
|
||||
db/purge.sql destructive, run only by an explicit admin purge
|
||||
model/worldStatus/ the .db.js / .model.js pair
|
||||
@@ -38,6 +43,7 @@ server/
|
||||
scripts/checkImports.js the module boundary, enforced
|
||||
scripts/swaggerFragment.js generates swagger-fragment.json from your own routes
|
||||
test/ the suites — start with entry.test.js
|
||||
test/eventActions.test.js the four traps chapter 5 is about, each as a failing test
|
||||
client/
|
||||
vite.config.js the library build: anchored aliases, external: []
|
||||
src/entry.jsx registers routes, nav and declared slots at evaluation time
|
||||
@@ -113,6 +119,7 @@ backticking table names**.
|
||||
| `server/package.json` | package `name` and `description` |
|
||||
| `server/core.js` | the message every accessor throws |
|
||||
| `server/index.js` | the trigger, audience, template and rule-group ids — all four are namespaced with your module id, and core refuses them otherwise |
|
||||
| `server/config/eventActions.js` | the budget, option-source, lease and action ids — four separate id spaces, each namespaced with your module id — and every command name the client sends |
|
||||
| `server/boot.js` | the placeholder world name |
|
||||
| `server/db/schema.sql` | every table name — the prefix must be your id |
|
||||
| `server/db/purge.sql` | the same table names |
|
||||
@@ -125,6 +132,7 @@ backticking table names**.
|
||||
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
|
||||
| `server/test/_fakes.js` | `ctx.moduleId` |
|
||||
| `server/test/entry.test.js` | the trigger id the world-status test asserts |
|
||||
| `server/test/eventActions.test.js` | the action and lease ids it looks up, and the clan fixture |
|
||||
| `server/test/worldStatus.test.js` | the fixture's world name |
|
||||
| `server/test/clanProvider.test.js` | the fixture's world name |
|
||||
| `server/package-lock.json` | **regenerated** — `npm install --prefix server` |
|
||||
@@ -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
|
||||
by the second edit.
|
||||
|
||||
**`server/sidecarClient.js` is not on that list and is not an oversight.** It
|
||||
carries no placeholder id — its vocabulary is the game's, not the module's — so
|
||||
the checker has nothing to hold it to. It is still the file you have the most work
|
||||
in: replace `deliver()` with one request to your sidecar, replace the fake game's
|
||||
verbs with your game's, and set `TIMEOUT_MS` to what your transport actually
|
||||
waits. Chapter 5 is mostly about that file.
|
||||
|
||||
Two things you do **not** rename: the mount prefixes `/world` and `/clans` need
|
||||
not be your id (the server's prefix namespace is shared with core's — `/status`,
|
||||
`/settings`, `/version`, `/contact` and `/teams` are already taken, which is why
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "examplegame",
|
||||
"name": "Example Game",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.9.0",
|
||||
"coreApi": "^1.10.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
|
||||
@@ -31,6 +31,7 @@ const core = require('./core')
|
||||
|
||||
const worldStatusDb = require('./model/worldStatus/worldStatus.db')
|
||||
const clanDb = require('./model/clans/clanProvider.db')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
const log = core.logger('boot')
|
||||
|
||||
@@ -39,6 +40,10 @@ const log = core.logger('boot')
|
||||
// so that there is something for the shutdown hook to actually do.
|
||||
let refreshTimer = null
|
||||
|
||||
// The last boot id the game reported. `null` means "never observed", which is not
|
||||
// the same as "changed" — see `checkForRestart`.
|
||||
let lastBootId = null
|
||||
|
||||
const REFRESH_MS = 30 * 1000
|
||||
|
||||
/**
|
||||
@@ -70,6 +75,9 @@ async function refresh() {
|
||||
const previous = await worldStatusDb.getStatus()
|
||||
await worldStatusDb.setStatus(next)
|
||||
|
||||
// Same poll, different question: did the thing we lit beacons in restart?
|
||||
checkForRestart()
|
||||
|
||||
// `previous === null` is the first boot on a fresh install, not a change.
|
||||
// Treating it as one would announce the world coming online to everyone the
|
||||
// first time an operator started the site.
|
||||
@@ -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.
|
||||
*
|
||||
@@ -162,7 +206,8 @@ async function onBoot() {
|
||||
async function onShutdown() {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
lastBootId = null
|
||||
log.info('shut down')
|
||||
}
|
||||
|
||||
module.exports = { onBoot, onShutdown, refresh, seedClans, REFRESH_MS }
|
||||
module.exports = { onBoot, onShutdown, refresh, seedClans, checkForRestart, REFRESH_MS }
|
||||
|
||||
447
template/server/config/eventActions.js
Normal file
447
template/server/config/eventActions.js
Normal file
@@ -0,0 +1,447 @@
|
||||
// ── What an event author can reach for ────────────────────────────────────
|
||||
//
|
||||
// MODULE_API.md 1.10.0 and `website/EVENTS.md` §F. Four declarations, all
|
||||
// optional, and together they are how a scheduled event on the website reaches
|
||||
// into your game and comes back out again.
|
||||
//
|
||||
// **All of it is optional, and that is the contract's own posture, not a hedge.**
|
||||
// A deployment with none of this installed still has a working event engine: it
|
||||
// can announce, wait, cue a human and publish results, over core's own verbs.
|
||||
// What these four add is the ability for an event to reach the GAME. A module
|
||||
// that registers none of them costs its deployment a capability, never a boot.
|
||||
//
|
||||
// ── The order to read this file in ────────────────────────────────────────
|
||||
//
|
||||
// A BUDGET names a resource dimension core can bound. An OPTION SOURCE answers a
|
||||
// dropdown on the authoring form. A LEASE is a value a run may BORROW, with a
|
||||
// deadline. An ACTION is a verb a run may perform, and what it makes it OWNS
|
||||
// until teardown.
|
||||
//
|
||||
// Four separate id spaces, each namespaced with your module id. `examplegame.beacons`
|
||||
// as a budget and `examplegame.beacon.light` as an action are not a collision;
|
||||
// reading them as one would forbid the most natural set of names you will ever write.
|
||||
//
|
||||
// ── Own versus borrow, and which one to build first ───────────────────────
|
||||
//
|
||||
// This file declares one of each on purpose, and if you only have time for one,
|
||||
// **build the lease.** `EVENTS.md` §H is blunt about it: the lease is the
|
||||
// primitive that travels and object creation is the special case. "Double the
|
||||
// gather rate for the weekend" is the canonical community event in almost every
|
||||
// game — set a value, hold it, put it back — while spawning creatures at a
|
||||
// landmark is a shape one genre happens to have. A lease is also the cheaper
|
||||
// thing to make safe, because the value you are replacing already existed and
|
||||
// reading it first gives you a baseline for free.
|
||||
//
|
||||
// ── The four things that are invisible until an outage ────────────────────
|
||||
//
|
||||
// Everything below is ordinary except four rules, and all four are the kind that
|
||||
// look like they are working right up until the day something is down. They are
|
||||
// marked TRAP 1..4 where they bite. In short:
|
||||
//
|
||||
// 1. **No shape a failure can take reads as success**, and `retry: true` is the
|
||||
// default — so `budgetMs` must EXCEED your transport's own timeout or your
|
||||
// own `retry: false` is unreachable code. The reason a refusal gives goes in
|
||||
// `error`; core reads no other name.
|
||||
// 2. **Pass `idempotencyKey` through, unchanged, on every attempt** — and put
|
||||
// it on a COMMAND, never on a question. It is the only thing standing
|
||||
// between a retry and a second world change, and the only thing that can
|
||||
// make a read permanently stale.
|
||||
// 3. **Core records a resource BEFORE it is confirmed**, so `revert` will be
|
||||
// called about things that may never have existed — and about nothing at
|
||||
// all, with only a key.
|
||||
// 4. **`cost` is priced before dispatch and never reconciled against what came
|
||||
// back**, so an action that under-declares turns every cap into a lie.
|
||||
|
||||
const core = require('../core')
|
||||
const sidecar = require('../sidecarClient')
|
||||
const clanDb = require('../model/clans/clanProvider.db')
|
||||
|
||||
const log = core.logger('events')
|
||||
|
||||
// ── TRAP 1 ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Core's dispatcher enforces `budgetMs`. When it expires the dispatcher stops
|
||||
// waiting and classifies the failure as **retry**, unconditionally, without
|
||||
// asking the action — it cannot ask, the action is still awaiting a socket.
|
||||
//
|
||||
// So an action whose own client gives up AFTER core's deadline never gets to
|
||||
// classify its own failure, and every `retry: false` it might return is
|
||||
// unreachable code. Core's default `budgetMs` is 10s; this module's client waits
|
||||
// 12s; on the default the deadline would fire first on every slow game and the
|
||||
// step would be retried by core no matter what this file says.
|
||||
//
|
||||
// Hence: strictly greater than `sidecar.TIMEOUT_MS`, derived from it rather than
|
||||
// typed beside it, and asserted in `test/eventActions.test.js`. Deriving it is
|
||||
// the part worth copying — a constant typed twice drifts the first time somebody
|
||||
// tunes the client and does not think to look here.
|
||||
const BUDGET_MS = sidecar.TIMEOUT_MS + 3000
|
||||
|
||||
// How many beacons one step may ask for. A bound in the module, in front of the
|
||||
// operator's cap rather than instead of it: this one is what the GAME can stand,
|
||||
// and the cap is what this deployment allows. Pre-checking here is what lets a
|
||||
// dry run show an author the refusal rather than a run meeting it at 3am.
|
||||
const MAX_BEACONS = 25
|
||||
|
||||
// Statuses the far end uses to mean "this will never work". Everything else —
|
||||
// including a timeout, a transport error and anything unrecognised — is left to
|
||||
// the default, which is a retry. That direction is deliberate: see the envelope
|
||||
// note on `classify` below.
|
||||
const PERMANENT = new Set(['unknown-command', 'no-idempotency-key'])
|
||||
|
||||
/**
|
||||
* One place that turns a client reply into an envelope core understands.
|
||||
*
|
||||
* Worth having as a function even with two callers. The rule it encodes —
|
||||
* "unrecognised means retry" — is the one you want stated once, because the
|
||||
* failure mode of getting it wrong per-action is a verb that quietly stops
|
||||
* retrying and nobody notices until a shard reboots mid-event.
|
||||
*/
|
||||
function classify(answer) {
|
||||
// **The field is `error`, not `detail`.** Core's dispatcher reads exactly two
|
||||
// things off a failure envelope — `ok` and `retry` — and passes `error`
|
||||
// through as the message an operator sees on the run console and an author
|
||||
// sees on a dry run. Anything under another name is dropped in silence, so an
|
||||
// action that puts its reason in `detail` produces a refusal that reads
|
||||
// "<action id> refused" and tells nobody why. Writing this template is how
|
||||
// that was found: `EVENTS.md` §H names a `detail` member in passing and core
|
||||
// has never read one.
|
||||
return { ok: false, retry: !PERMANENT.has(answer.status), error: answer.status }
|
||||
}
|
||||
|
||||
// ══ BUDGETS ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// A dimension core can count and bound. Core never learns what a beacon is: it
|
||||
// holds `{ dimension, consumed, cap }` and the vocabulary stays here. That is the
|
||||
// whole of what makes the engine game-agnostic at this seam.
|
||||
//
|
||||
// **Declaring a dimension is not the same as bounding it.** A declared dimension
|
||||
// with no operator cap is counted and unbounded — which is useful on its own,
|
||||
// because the run console then shows an author what their event actually spent.
|
||||
const BUDGETS = [
|
||||
{ id: 'examplegame.beacons', label: 'Beacons lit', unit: 'count' },
|
||||
]
|
||||
|
||||
// ══ OPTION SOURCES ════════════════════════════════════════════════════════
|
||||
//
|
||||
// What a dropdown on the authoring form is filled from. A fourth registration
|
||||
// rather than a field on the action, because a catalog usually has more than one
|
||||
// consumer — this one answers both the action's `clanId` param and the lease's
|
||||
// target would, if the lease were targeted — and two actions declaring it
|
||||
// separately would be two allowlists that can disagree.
|
||||
//
|
||||
// **A source that refuses degrades its field to free text with a warning.** It
|
||||
// never blocks the form and it never raises, so this resolver may read the
|
||||
// database and may fail. Do not defend against that by returning a hardcoded
|
||||
// list; an empty answer with a log line is more honest than a stale one.
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'examplegame.options.clans',
|
||||
label: 'Clans',
|
||||
// Core passes `q` to EVERY source and requires it of none, so a resolver
|
||||
// written before search existed behaves identically. Declare
|
||||
// `searchable: true` when the term actually narrows the answer — the form
|
||||
// reads it to choose between a typeahead and a select. Do not infer it from
|
||||
// the length of the list: that reads correctly right up until a small
|
||||
// deployment's list happens to fit in a dropdown.
|
||||
async resolve() {
|
||||
try {
|
||||
const clans = await clanDb.listClans()
|
||||
return clans.map((c) => ({ value: c.externalId, label: c.name }))
|
||||
} catch (err) {
|
||||
log.warn('option source failed', { source: 'examplegame.options.clans', error: err.message })
|
||||
return []
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ══ LEASES ════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// A value a run BORROWS and gives back. The module declares what can be held and
|
||||
// how long; **the verb is core's** — an author puts `core.lease` in a step, and
|
||||
// core reads the baseline, reserves the target in its resource ledger, applies
|
||||
// the value with a deadline, and restores it at teardown through `restore()`
|
||||
// below. A lease verb of your own would be that duration bound and that
|
||||
// two-events-one-target check re-implemented once per module, advisory
|
||||
// everywhere, and wrong in the first one that forgot it.
|
||||
//
|
||||
// **Only advertise a lease you have verified takes effect.** A value your game
|
||||
// reads once at start-up and caches will apply cleanly, read back cleanly and do
|
||||
// nothing — a capability that lies, which no amount of core-side checking can
|
||||
// catch. Apply it, observe it, restore it, as a test, per key.
|
||||
const LEASES = [
|
||||
{
|
||||
id: 'examplegame.rate.gather',
|
||||
label: 'Gather rate',
|
||||
type: 'float',
|
||||
min: 0.5,
|
||||
max: 5,
|
||||
// The longest core will let a run hold it. A weekend, here. The bound is
|
||||
// core's to enforce and yours to choose, and it should be the longest you
|
||||
// would be comfortable finding still applied after everything else broke.
|
||||
maxDurationMs: 48 * 60 * 60 * 1000,
|
||||
|
||||
/** The baseline, read live. Core stores what this answers and restores to it. */
|
||||
async read() {
|
||||
// `ask`, not `send`. A read carrying an idempotency key would be answered
|
||||
// with the FIRST read's value forever — see `sidecarClient.js`'s header.
|
||||
const answer = await sidecar.ask('rate.gather.read')
|
||||
return answer.ok ? { ok: true, value: answer.data.value } : classify(answer)
|
||||
},
|
||||
|
||||
/**
|
||||
* Hold the value until `until`.
|
||||
*
|
||||
* **`until` goes down the wire and the far end honours it without being asked
|
||||
* again.** Core's copy of the deadline is for the console; the game's copy is
|
||||
* the fail-safe. A module that passes it and then relies on core to come back
|
||||
* and restore has built a lease that outlives an outage — which is the one
|
||||
* thing a lease exists to prevent.
|
||||
*/
|
||||
async apply(value, until) {
|
||||
// No idempotency key, and that is deliberate rather than an omission:
|
||||
// setting a value to X twice is setting it to X. A key here would buy
|
||||
// nothing and cost the reply's freshness.
|
||||
const answer = await sidecar.send('rate.gather.apply', {
|
||||
value,
|
||||
until: until instanceof Date ? until.toISOString() : until,
|
||||
})
|
||||
return answer.ok ? { ok: true } : classify(answer)
|
||||
},
|
||||
|
||||
/**
|
||||
* Put it back.
|
||||
*
|
||||
* `expected` is what core believes is currently applied. Answering that the
|
||||
* live value differs is how a lease lands `drifted` with the current value
|
||||
* beside it, rather than core silently overwriting whatever a human changed
|
||||
* mid-event. Restoring must be idempotent for the same reason `revert` must:
|
||||
* core may ask more than once.
|
||||
*/
|
||||
async restore(baseline, { expected } = {}) {
|
||||
const live = await sidecar.ask('rate.gather.read')
|
||||
if (!live.ok) return classify(live)
|
||||
if (expected !== undefined && Number(live.data.value) !== Number(expected)) {
|
||||
return { ok: true, drifted: true, value: live.data.value }
|
||||
}
|
||||
const answer = await sidecar.send('rate.gather.restore', { value: baseline })
|
||||
return answer.ok ? { ok: true } : classify(answer)
|
||||
},
|
||||
|
||||
/**
|
||||
* A FOURTH question, not a fourth spelling of `read()`.
|
||||
*
|
||||
* "Does the game side still have any record of this hold?" A value that
|
||||
* DIFFERS from what the run applied is drift, which `restore()` reports; a
|
||||
* reconcile that inferred absence from a changed value would take the row out
|
||||
* and tell an operator the lease vanished rather than that somebody moved it.
|
||||
*
|
||||
* Optional, and answering `{ ok: true, held: false }` is the only thing that
|
||||
* takes a lease's ledger row out. Everything else — a throw, a refusal, no
|
||||
* `inForce` at all — leaves the row alone, which is the same
|
||||
* "I do not know is never it is gone" rule the actions below follow.
|
||||
*/
|
||||
async inForce() {
|
||||
const live = await sidecar.ask('rate.gather.read')
|
||||
if (!live.ok) return classify(live)
|
||||
return { ok: true, held: Number(live.data.value) !== 1.0 }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ══ ACTIONS ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const ACTIONS = [
|
||||
{
|
||||
id: 'examplegame.beacon.light',
|
||||
label: 'Light beacons',
|
||||
description: "Lights signal beacons at a clan's hall for the length of this event.",
|
||||
|
||||
// Both are closed sets core interprets, and neither is decoration: `risk`
|
||||
// decides which role may put this in a step and whether it is off by default,
|
||||
// and `reversible` decides whether core will ever call `revert`.
|
||||
risk: 'change', // notify | inspect | change | irreversible
|
||||
reversible: 'ledger', // none | self | ledger | override
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS, // TRAP 1 — see the constant
|
||||
|
||||
// ── TRAP 4 ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// What ONE invocation consumes. A function, because it depends on the params.
|
||||
//
|
||||
// **Core prices this BEFORE dispatch and never reconciles it against what
|
||||
// came back.** There is no check that the `resources` you return match what
|
||||
// you said you would spend — there cannot be, since core does not know what a
|
||||
// beacon is. So an action that returns `{ 'examplegame.beacons': 1 }` while
|
||||
// lighting twelve turns an operator's cap of 30 into a cap of 360, and the
|
||||
// meter on the run console agrees with the lie. Nothing goes red. The first
|
||||
// symptom is a world with an order of magnitude more in it than anyone
|
||||
// authorised.
|
||||
//
|
||||
// Count what you will actually make, from the params you were given, every
|
||||
// time. If you cannot know until the answer comes back, declare the maximum:
|
||||
// a spend that is too high refuses an event that would have fit, which an
|
||||
// author can see and argue with, and one that is too low cannot be seen at all.
|
||||
//
|
||||
// A `cost()` naming a dimension no module declared is REFUSED — at save, at
|
||||
// the dry run and at dispatch — because the fix is a module's declaration and
|
||||
// not a deployment's cap.
|
||||
cost: (p) => ({ 'examplegame.beacons': Number(p.count) || 0 }),
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'clanId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
// `example` is required on every param, optional ones included. It is the
|
||||
// authoring form's placeholder, it is one word at declaration time, and
|
||||
// it is unreconstructable afterwards by anybody who did not write the action.
|
||||
example: 'clan-1',
|
||||
source: 'examplegame.options.clans',
|
||||
},
|
||||
{ name: 'count', type: 'int', required: true, example: 6 },
|
||||
],
|
||||
|
||||
/**
|
||||
* Do it.
|
||||
*
|
||||
* @param {object} env
|
||||
* @param {string} env.runId
|
||||
* @param {string} env.stepId
|
||||
* @param {string} env.idempotencyKey a function of identity, never of attempt
|
||||
* @param {*} env.scope opaque to core; may be null
|
||||
* @param {object} env.params
|
||||
* @param {object} env.actor
|
||||
* @param {boolean} env.verify dry run: validate, change NOTHING
|
||||
*/
|
||||
async perform({ idempotencyKey, params, verify }) {
|
||||
const count = Number(params.count)
|
||||
if (!Number.isInteger(count) || count < 1 || count > MAX_BEACONS) {
|
||||
// A refusal the second attempt would repeat verbatim, so `retry: false`.
|
||||
// This is the arm TRAP 1 exists to keep reachable.
|
||||
return { ok: false, retry: false, error: `count must be 1..${MAX_BEACONS}` }
|
||||
}
|
||||
|
||||
// **`verify` must change nothing and must answer honestly.** It rides this
|
||||
// same dispatcher a real run uses, because a dry run down a second code
|
||||
// path is a dry run of the second path. Validate everything you can reach
|
||||
// without writing — the params above, and a lookup below — then stop.
|
||||
if (verify) {
|
||||
const clan = await clanDb.findClan(params.clanId)
|
||||
return clan
|
||||
? { ok: true }
|
||||
: { ok: false, retry: false, error: `no such clan: ${params.clanId}` }
|
||||
}
|
||||
|
||||
// ── TRAP 2 ────────────────────────────────────────────────────────
|
||||
//
|
||||
// The key goes through, unchanged. Core derives it from the step's identity
|
||||
// and never from the attempt number, so every retry carries the same one —
|
||||
// and the far end, which is the only end that can tell a retry from a
|
||||
// repeat, answers a key it already executed with the ORIGINAL reply rather
|
||||
// than running it again.
|
||||
//
|
||||
// A module that generates its own key here, or drops it, has an action that
|
||||
// cannot be retried safely, and the cost of that is not a failed step: it
|
||||
// is a second world change on a socket hiccup. It looks like it works in
|
||||
// every test, because in every test the first attempt succeeds.
|
||||
const answer = await sidecar.send(
|
||||
'beacon.light',
|
||||
{ clanId: params.clanId, count },
|
||||
{ idempotencyKey },
|
||||
)
|
||||
if (!answer.ok) return classify(answer)
|
||||
|
||||
// What core writes into its ledger. `kind` is yours; `ref` is whatever you
|
||||
// will need to undo it. The boot stamp rides along because `reconcile`
|
||||
// below is the only thing that reads it — see its note.
|
||||
return {
|
||||
ok: true,
|
||||
resources: answer.data.refs.map((ref) => ({
|
||||
kind: 'beacon',
|
||||
ref,
|
||||
meta: { bootId: answer.data.bootId },
|
||||
})),
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Undo it. **Required, because `reversible` is `'ledger'`.**
|
||||
*
|
||||
* Called by core's cleanup sweep at teardown, over the rows this action's
|
||||
* `resources` produced — a LIST, so twelve beacons are one round trip rather
|
||||
* than twelve. Cleanup is derived rather than authored: there is no
|
||||
* `on_teardown` and no cleanup phase in a spec, because an operator cannot be
|
||||
* relied on to write the undo and an aborted run never reaches the phase they
|
||||
* wrote it in. It runs on every terminal path — completion, cancellation and
|
||||
* abort alike.
|
||||
*
|
||||
* ── TRAP 3 ──────────────────────────────────────────────────────────
|
||||
*
|
||||
* Two things follow from `EVENTS.md` §D rule 1, *core records a resource
|
||||
* BEFORE it is confirmed*:
|
||||
*
|
||||
* • **Reverting something that does not exist is a SUCCESS.** A dispatch
|
||||
* whose answer was lost leaves a ledger row for something that may never
|
||||
* have existed, and cleanup will ask about it. You must never have to
|
||||
* tell "I removed it" from "it was not there" — and you could not, because
|
||||
* the far end cannot either. Answer `{ ok: true }`.
|
||||
*
|
||||
* • **You will be called with NO resources and only a key.** That is the
|
||||
* lost-answer case stated exactly: core knows a dispatch went out under
|
||||
* this key and never learned what it made. A module that can undo by key
|
||||
* answers honestly. One that cannot answers `{ ok: false }`, and the row
|
||||
* stays visible to an operator — which is the correct outcome, not a
|
||||
* silent one. Answering `{ ok: true }` to a question you cannot answer is
|
||||
* how a beacon burns forever with core's ledger reporting it cleaned up.
|
||||
*
|
||||
* And it must be idempotent, because core may ask more than once.
|
||||
*/
|
||||
async revert({ resources, idempotencyKey }) {
|
||||
const refs = (resources || []).map((r) => r.ref).filter(Boolean)
|
||||
|
||||
if (refs.length === 0) {
|
||||
// The lost-answer case. This module CAN answer it, because the far end
|
||||
// stores what each key produced — so asking it to undo the key is a real
|
||||
// question with a real answer. If yours cannot, say `{ ok: false }` here
|
||||
// and let a human see the row.
|
||||
const byKey = await sidecar.send('beacon.douse', { refs: [] }, { idempotencyKey })
|
||||
return byKey.ok ? { ok: true } : classify(byKey)
|
||||
}
|
||||
|
||||
const answer = await sidecar.send('beacon.douse', { refs }, { idempotencyKey })
|
||||
if (!answer.ok) return classify(answer)
|
||||
// `{ ok: true }` reverts the whole group. Name the ones that did not come
|
||||
// back in `failed: [...]` and core keeps exactly those rows.
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
/**
|
||||
* Which of these does the game still have? **Optional.**
|
||||
*
|
||||
* Asked after something outside core restarted — core's own boot, or this
|
||||
* module calling `core.reconcileEvents()` because it saw the boot id change.
|
||||
*
|
||||
* The asymmetry with `revert` is the design: a module that cannot say what the
|
||||
* game still has is not broken, and core keeps believing its own ledger. One
|
||||
* that created something and cannot undo it has made a promise core has no way
|
||||
* to keep. So `revert` is required and this is not.
|
||||
*
|
||||
* **Anything that is not an explicit `{ ok: true, inForce: [...] }` leaves the
|
||||
* ledger alone.** "I do not know" is never read as "it is gone", and a
|
||||
* resource reported missing becomes `orphaned` rather than `reverted` —
|
||||
* because nobody asked for it to go.
|
||||
*/
|
||||
async reconcile({ resources }) {
|
||||
const refs = (resources || []).map((r) => r.ref).filter(Boolean)
|
||||
// A question, so `ask`. Keying this one would have pinned the answer to
|
||||
// whatever was in force the first time core ever swept — which is the exact
|
||||
// opposite of what a reconcile is for.
|
||||
const answer = await sidecar.ask('beacon.inForce', { refs })
|
||||
if (!answer.ok) return classify(answer)
|
||||
return { ok: true, inForce: answer.data.refs }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { BUDGETS, OPTION_SOURCES, LEASES, ACTIONS, BUDGET_MS, MAX_BEACONS, classify }
|
||||
@@ -106,6 +106,23 @@ module.exports = {
|
||||
// operator's log six weeks later.
|
||||
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope),
|
||||
|
||||
// Telling core the game restarted (MODULE_API.md §2.3, 1.10.0). The one thing
|
||||
// the event contract adds to `ctx`, and it is here for a reason worth carrying:
|
||||
// **core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
|
||||
// and cannot tell a wedged sidecar from a shard that rebooted and lost every
|
||||
// creature an event spawned. Only this module knows, because only this module
|
||||
// watches the feed the boot id arrives on.
|
||||
//
|
||||
// Calling it asks core to sweep its resource ledger and put the question back
|
||||
// to this module's actions, as `reconcile({ runId, resources })`. Fire and
|
||||
// forget: it returns at once and the sweep happens on core's own time.
|
||||
//
|
||||
// See `boot.js` for the watch that calls it, and `config/eventActions.js` for
|
||||
// the answer. Named longer than the `ctx` member it wraps because this object
|
||||
// is flat — `core.emit` is already a little ambiguous and `core.reconcile()`
|
||||
// would be worse, since a module has more than one thing it could reconcile.
|
||||
reconcileEvents: () => need().events.reconcile(),
|
||||
|
||||
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
||||
// only correct way to find a file you shipped, because the working directory is
|
||||
// core's and the module's location is the loader's business.
|
||||
|
||||
@@ -50,6 +50,7 @@ module.exports = function register(ctx, api) {
|
||||
const worldRouter = require('./router/public/world.router')
|
||||
const clansRouter = require('./router/public/clans.router')
|
||||
const clanProvider = require('./model/clans/clanProvider.model')
|
||||
const eventActions = require('./config/eventActions')
|
||||
const boot = require('./boot')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
|
||||
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.
|
||||
// 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.
|
||||
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: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
@@ -93,6 +97,7 @@ function fakeApi() {
|
||||
const record = {
|
||||
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
|
||||
triggers: null, audiences: null, engagementSeeds: null,
|
||||
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
|
||||
}
|
||||
const called = new Set()
|
||||
const once = (name) => {
|
||||
@@ -114,6 +119,13 @@ function fakeApi() {
|
||||
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
||||
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
||||
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
||||
// The event contract (1.10.0). `once` on all four: a batch is a module's
|
||||
// COMPLETE statement about what it declares, so a second call is a module
|
||||
// changing its mind halfway through `register()` rather than adding to it.
|
||||
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
||||
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
|
||||
@@ -290,3 +290,64 @@ test('the world event fires on the transition and not on the poll', async () =>
|
||||
await boot.refresh()
|
||||
assert.deepStrictEqual(fresh.events.emit.calls, [])
|
||||
})
|
||||
|
||||
test('the four event declarations are registered, each exactly once', () => {
|
||||
const { api } = register()
|
||||
|
||||
// Every one of the four is optional (§F), so this asserts what THIS module
|
||||
// chose rather than what core requires. What it is really checking is that
|
||||
// `index.js` still hands core the arrays `config/eventActions.js` exports —
|
||||
// the failure it catches is a rename on one side and not the other, which
|
||||
// costs a deployment a capability with nothing red anywhere.
|
||||
assert.ok(Array.isArray(api.record.eventBudgets))
|
||||
assert.ok(Array.isArray(api.record.eventOptionSources))
|
||||
assert.ok(Array.isArray(api.record.eventLeases))
|
||||
assert.ok(Array.isArray(api.record.eventActions))
|
||||
|
||||
// `once` on all four: a batch is a module's COMPLETE statement about what it
|
||||
// declares. `fakeApi` throws on a second call, so registering twice fails here.
|
||||
assert.ok(api.record.eventActions.length > 0)
|
||||
})
|
||||
|
||||
test('an action may only spend a budget dimension some module declared', () => {
|
||||
const { api } = register()
|
||||
|
||||
// Core refuses a `cost()` naming an undeclared dimension at save, at the dry
|
||||
// run and at dispatch, because the fix is a module's declaration rather than a
|
||||
// deployment's cap. This module declares everything it spends, so the check is
|
||||
// local; a module spending another module's dimension would have to loosen it.
|
||||
const declared = new Set(api.record.eventBudgets.map((b) => b.id))
|
||||
for (const action of api.record.eventActions) {
|
||||
const sample = Object.fromEntries(action.params.map((p) => [p.name, p.example]))
|
||||
for (const dimension of Object.keys(action.cost(sample))) {
|
||||
assert.ok(declared.has(dimension), `${action.id} spends undeclared ${dimension}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a game restart asks core to reconcile, and a first sighting does not', () => {
|
||||
const boot = require('../boot')
|
||||
const sidecar = require('../sidecarClient')
|
||||
|
||||
const ctx = fakeCtx()
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
|
||||
// First observation is not a restart. Treating it as one would sweep every
|
||||
// ledgered resource on every website deploy, for no news.
|
||||
boot.checkForRestart()
|
||||
assert.deepStrictEqual(ctx.events.reconcile.calls, [])
|
||||
|
||||
// Same boot id: still nothing.
|
||||
boot.checkForRestart()
|
||||
assert.deepStrictEqual(ctx.events.reconcile.calls, [])
|
||||
|
||||
// The game came back as something else. Core cannot see this and must be told.
|
||||
sidecar.simulateRestart()
|
||||
boot.checkForRestart()
|
||||
assert.strictEqual(ctx.events.reconcile.calls.length, 1)
|
||||
|
||||
// And only once for one restart.
|
||||
boot.checkForRestart()
|
||||
assert.strictEqual(ctx.events.reconcile.calls.length, 1)
|
||||
})
|
||||
|
||||
399
template/server/test/eventActions.test.js
Normal file
399
template/server/test/eventActions.test.js
Normal file
@@ -0,0 +1,399 @@
|
||||
// ── The four traps, as tests ──────────────────────────────────────────────
|
||||
//
|
||||
// `config/eventActions.js` marks four rules TRAP 1..4 and says all four are
|
||||
// invisible until an outage. That is a bad property for a rule to have and a good
|
||||
// reason to test it, because the alternative is finding out in production once.
|
||||
//
|
||||
// Each of the four gets a test that FAILS if the rule is broken — not one that
|
||||
// asserts the current value. Trap 1 in particular is asserted as an inequality
|
||||
// between two constants that live in different files, which is the only form that
|
||||
// survives somebody tuning the client.
|
||||
//
|
||||
// Everything here runs without core, without a database and without a game: the
|
||||
// declarations are plain objects and the client's transport is simulated. What it
|
||||
// cannot prove is that core accepts these declarations — a fake that agreed with
|
||||
// a mistake is exactly how a module ships green and refuses to load. That check
|
||||
// is `checkCoreApi.js` plus a run against a real core, and the kit's
|
||||
// `ci/core-ref.json` is where its date is written down.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
const core = require('../core')
|
||||
|
||||
core.init(fakeCtx())
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const events = require('../config/eventActions')
|
||||
const sidecar = require('../sidecarClient')
|
||||
const clanDb = require('../model/clans/clanProvider.db')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
// Stubbed at the `.db.js` seam, the same way `clanProvider.test.js` does it:
|
||||
// there is no database here, and an action's `verify` reads one.
|
||||
const CLANS = [{ externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 3 }]
|
||||
clanDb.listClans = async () => CLANS
|
||||
clanDb.findClan = async (externalId) => CLANS.find((c) => c.externalId === externalId)
|
||||
|
||||
const action = events.ACTIONS.find((a) => a.id === 'examplegame.beacon.light')
|
||||
const lease = events.LEASES.find((l) => l.id === 'examplegame.rate.gather')
|
||||
|
||||
/** A fresh key per call, the way core's is a function of a step's identity. */
|
||||
let keyCounter = 0
|
||||
const nextKey = () => `test-key-${(keyCounter += 1)}`
|
||||
|
||||
// ══ Shape ═════════════════════════════════════════════════════════════════
|
||||
|
||||
test('every declaration is namespaced with the module id', () => {
|
||||
const ids = [
|
||||
...events.BUDGETS.map((b) => b.id),
|
||||
...events.OPTION_SOURCES.map((s) => s.id),
|
||||
...events.LEASES.map((l) => l.id),
|
||||
...events.ACTIONS.map((a) => a.id),
|
||||
]
|
||||
for (const id of ids) {
|
||||
assert.ok(id.startsWith('examplegame.'), `${id} is not namespaced — core refuses it`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every param declares an example, optional ones included', () => {
|
||||
for (const a of events.ACTIONS) {
|
||||
for (const p of a.params) {
|
||||
assert.ok(p.example !== undefined, `${a.id}.${p.name} has no example`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("an action's `source` names an option source this module registers", () => {
|
||||
// Core resolves this across every module, so a source another module owns is
|
||||
// legal. Checking the local case is still worth doing: a typo in your own id is
|
||||
// the overwhelmingly likely mistake, and it degrades the field to free text in
|
||||
// silence rather than failing.
|
||||
const sources = new Set(events.OPTION_SOURCES.map((s) => s.id))
|
||||
for (const a of events.ACTIONS) {
|
||||
for (const p of a.params) {
|
||||
if (p.source && p.source.startsWith('examplegame.')) {
|
||||
assert.ok(sources.has(p.source), `${a.id}.${p.name} names an unregistered source`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("an action that ledgers declares `revert`", () => {
|
||||
for (const a of events.ACTIONS) {
|
||||
if (a.reversible === 'ledger') {
|
||||
assert.strictEqual(typeof a.revert, 'function', `${a.id} ledgers but cannot undo`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ══ TRAP 1 — the failure default, and the budget that makes it reachable ══
|
||||
|
||||
test('TRAP 1: budgetMs strictly exceeds the client timeout', () => {
|
||||
// The inequality, not the value. Core classifies a budget timeout as a retry
|
||||
// WITHOUT asking the action, so if this ever inverts, every `retry: false`
|
||||
// below becomes unreachable code and nothing else in this suite would notice —
|
||||
// the action would still return it, and core would still retry.
|
||||
for (const a of events.ACTIONS) {
|
||||
assert.ok(
|
||||
a.budgetMs > sidecar.TIMEOUT_MS,
|
||||
`${a.id}: budgetMs ${a.budgetMs} must exceed the client's ${sidecar.TIMEOUT_MS}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('TRAP 1: an unrecognised failure is a RETRY', () => {
|
||||
// The default direction. A module that listed the transient statuses and
|
||||
// defaulted the rest to terminal would stop retrying the moment its sidecar
|
||||
// grew a status nobody here had heard of.
|
||||
const verdict = events.classify({ ok: false, status: 'something-new' })
|
||||
assert.strictEqual(verdict.ok, false)
|
||||
assert.strictEqual(verdict.retry, true)
|
||||
})
|
||||
|
||||
test('TRAP 1: a timeout is a retry and an unknown command is not', () => {
|
||||
assert.strictEqual(events.classify({ ok: false, status: 'timeout' }).retry, true)
|
||||
assert.strictEqual(events.classify({ ok: false, status: 'unknown-command' }).retry, false)
|
||||
})
|
||||
|
||||
test('a refusal says WHY, in the field core actually reads', async () => {
|
||||
// Core's dispatcher carries `error` off a failure envelope and nothing else.
|
||||
// A reason under any other name — `detail`, `message`, `reason` — is dropped in
|
||||
// silence and the operator sees "<action id> refused". This test exists because
|
||||
// the first draft of this template used `detail`, on the strength of the one
|
||||
// place `EVENTS.md` mentions it, and every refusal it produced was anonymous.
|
||||
const answer = await action.perform({
|
||||
idempotencyKey: nextKey(),
|
||||
params: { clanId: 'clan-1', count: 0 },
|
||||
})
|
||||
assert.strictEqual(answer.ok, false)
|
||||
assert.strictEqual(typeof answer.error, 'string')
|
||||
assert.ok(answer.error.length > 0, 'a refusal with no `error` tells an author nothing')
|
||||
|
||||
// And the same for a failure this module classified rather than authored.
|
||||
assert.strictEqual(typeof events.classify({ ok: false, status: 'timeout' }).error, 'string')
|
||||
})
|
||||
|
||||
test('TRAP 1: a refusal the second attempt would repeat says retry: false', async () => {
|
||||
// The arm the inequality above exists to keep reachable. A count core would
|
||||
// hand back identically on a retry is not worth a second round trip.
|
||||
const answer = await action.perform({
|
||||
idempotencyKey: nextKey(),
|
||||
params: { clanId: 'clan-1', count: 9999 },
|
||||
})
|
||||
assert.strictEqual(answer.ok, false)
|
||||
assert.strictEqual(answer.retry, false)
|
||||
})
|
||||
|
||||
// ══ TRAP 2 — the idempotency passthrough ═════════════════════════════════
|
||||
|
||||
test("TRAP 2: perform passes core's key through, unchanged", async () => {
|
||||
const seen = []
|
||||
const realSend = sidecar.send
|
||||
// Wrapping the module's own client rather than a fake one: what is under test
|
||||
// is that the key reaches the call, and a fake client would only prove the
|
||||
// test passed it to itself.
|
||||
sidecar.send = async (command, payload, options) => {
|
||||
seen.push(options && options.idempotencyKey)
|
||||
return realSend(command, payload, options)
|
||||
}
|
||||
try {
|
||||
const key = nextKey()
|
||||
await action.perform({ idempotencyKey: key, params: { clanId: 'clan-1', count: 2 } })
|
||||
assert.deepStrictEqual(seen, [key], 'the key core gave us is not the key that went down the wire')
|
||||
} finally {
|
||||
sidecar.send = realSend
|
||||
}
|
||||
})
|
||||
|
||||
test('TRAP 2: a retry under the same key changes the world once', async () => {
|
||||
// The property the passthrough buys, stated as behaviour rather than as a
|
||||
// parameter. Two attempts, one key: the second collects the answer the first
|
||||
// already produced, and the refs are identical.
|
||||
const key = nextKey()
|
||||
const params = { clanId: 'clan-1', count: 3 }
|
||||
|
||||
const first = await action.perform({ idempotencyKey: key, params })
|
||||
const second = await action.perform({ idempotencyKey: key, params })
|
||||
|
||||
assert.strictEqual(first.ok, true)
|
||||
assert.strictEqual(second.ok, true)
|
||||
assert.deepStrictEqual(
|
||||
second.resources.map((r) => r.ref),
|
||||
first.resources.map((r) => r.ref),
|
||||
'the repeat produced NEW refs — that is two sets of beacons and one ledger',
|
||||
)
|
||||
})
|
||||
|
||||
test('TRAP 2: a fresh key on the same params is a second, real change', async () => {
|
||||
// The control for the test above. If this passed identically, the far end
|
||||
// would be deduplicating on the params rather than on the key, and the test
|
||||
// above would be proving nothing.
|
||||
const params = { clanId: 'clan-1', count: 3 }
|
||||
const first = await action.perform({ idempotencyKey: nextKey(), params })
|
||||
const second = await action.perform({ idempotencyKey: nextKey(), params })
|
||||
assert.notDeepStrictEqual(
|
||||
second.resources.map((r) => r.ref),
|
||||
first.resources.map((r) => r.ref),
|
||||
)
|
||||
})
|
||||
|
||||
test('TRAP 2: a call with no key is refused rather than sent', async () => {
|
||||
const answer = await sidecar.send('beacon.light', { clanId: 'clan-1', count: 1 }, {})
|
||||
assert.strictEqual(answer.ok, false)
|
||||
assert.strictEqual(answer.status, 'no-idempotency-key')
|
||||
})
|
||||
|
||||
// ══ TRAP 3 — core records a resource BEFORE it is confirmed ══════════════
|
||||
|
||||
test('TRAP 3: reverting something that was never made is a SUCCESS', async () => {
|
||||
const answer = await action.revert({
|
||||
idempotencyKey: nextKey(),
|
||||
resources: [{ kind: 'beacon', ref: 'beacon:never-existed:1' }],
|
||||
})
|
||||
// The message avoids the words `from "..."` on purpose: `checkImports.js` is
|
||||
// deliberately textual and reads that shape as an import specifier, prose or not.
|
||||
assert.strictEqual(answer.ok, true, 'removing something absent must be a success')
|
||||
})
|
||||
|
||||
test('TRAP 3: revert is idempotent — core may ask more than once', async () => {
|
||||
const made = await action.perform({
|
||||
idempotencyKey: nextKey(),
|
||||
params: { clanId: 'clan-1', count: 2 },
|
||||
})
|
||||
const first = await action.revert({ idempotencyKey: nextKey(), resources: made.resources })
|
||||
const again = await action.revert({ idempotencyKey: nextKey(), resources: made.resources })
|
||||
assert.strictEqual(first.ok, true)
|
||||
assert.strictEqual(again.ok, true)
|
||||
})
|
||||
|
||||
test('TRAP 3: revert is called with NO resources and only a key', async () => {
|
||||
// The lost-answer case: core knows a dispatch went out under this key and never
|
||||
// learned what it made. This module CAN answer it. One that cannot must say
|
||||
// `{ ok: false }` and let a human see the row — never `{ ok: true }`, which is
|
||||
// how a resource burns forever with the ledger reporting it cleaned up.
|
||||
const answer = await action.revert({ idempotencyKey: nextKey(), resources: [] })
|
||||
assert.strictEqual(answer.ok, true)
|
||||
})
|
||||
|
||||
test('TRAP 3: reconcile reports what is gone and never guesses', async () => {
|
||||
const made = await action.perform({
|
||||
idempotencyKey: nextKey(),
|
||||
params: { clanId: 'clan-1', count: 2 },
|
||||
})
|
||||
|
||||
const before = await action.reconcile({ resources: made.resources })
|
||||
assert.strictEqual(before.ok, true)
|
||||
assert.deepStrictEqual(before.inForce.sort(), made.resources.map((r) => r.ref).sort())
|
||||
|
||||
sidecar.simulateRestart()
|
||||
|
||||
const after = await action.reconcile({ resources: made.resources })
|
||||
assert.strictEqual(after.ok, true)
|
||||
assert.deepStrictEqual(after.inForce, [], 'a restart lost them; reconcile must say so')
|
||||
})
|
||||
|
||||
// ══ TRAP 4 — the cost that is priced and never reconciled ════════════════
|
||||
|
||||
test('TRAP 4: cost counts what one invocation actually makes', async () => {
|
||||
// The failure this catches is `() => ({ 'examplegame.beacons': 1 })`, which
|
||||
// would pass every other test in this file and turn an operator's cap of 30
|
||||
// into a cap of 750. Core prices `cost` before dispatch and NEVER reconciles it
|
||||
// against the resources that come back, so nothing else can catch it.
|
||||
const params = { clanId: 'clan-1', count: 7 }
|
||||
const priced = action.cost(params)
|
||||
const made = await action.perform({ idempotencyKey: nextKey(), params })
|
||||
|
||||
assert.strictEqual(
|
||||
priced['examplegame.beacons'],
|
||||
made.resources.length,
|
||||
'the action declared a different number than it made — every cap on this dimension is a lie',
|
||||
)
|
||||
})
|
||||
|
||||
test('TRAP 4: cost only names dimensions this module declared', () => {
|
||||
// A `cost()` naming an undeclared dimension is REFUSED at save, at the dry run
|
||||
// and at dispatch, because the fix is a module's declaration rather than a
|
||||
// deployment's cap. Cheaper to find here.
|
||||
const declared = new Set(events.BUDGETS.map((b) => b.id))
|
||||
for (const a of events.ACTIONS) {
|
||||
const sample = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
|
||||
for (const dimension of Object.keys(a.cost(sample))) {
|
||||
assert.ok(declared.has(dimension), `${a.id} spends ${dimension}, which no module here declares`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ══ verify ════════════════════════════════════════════════════════════════
|
||||
|
||||
test('verify changes nothing', async () => {
|
||||
const before = await action.reconcile({ resources: [] })
|
||||
const dry = await action.perform({
|
||||
idempotencyKey: nextKey(),
|
||||
params: { clanId: 'clan-1', count: 5 },
|
||||
verify: true,
|
||||
})
|
||||
assert.strictEqual(dry.ok, true)
|
||||
assert.strictEqual(dry.resources, undefined, 'a dry run must not report resources it did not make')
|
||||
|
||||
// Nothing was lit, so nothing new is in force. The assertion is weak on its own
|
||||
// and strong beside the TRAP 3 reconcile test above, which proves the same call
|
||||
// does see what `perform` makes.
|
||||
const after = await action.reconcile({ resources: [] })
|
||||
assert.deepStrictEqual(after.inForce, before.inForce)
|
||||
})
|
||||
|
||||
test('verify answers honestly rather than always true', async () => {
|
||||
const dry = await action.perform({
|
||||
idempotencyKey: nextKey(),
|
||||
params: { clanId: 'no-such-clan', count: 1 },
|
||||
verify: true,
|
||||
})
|
||||
assert.strictEqual(dry.ok, false)
|
||||
assert.strictEqual(dry.retry, false)
|
||||
})
|
||||
|
||||
// ══ The lease ═════════════════════════════════════════════════════════════
|
||||
|
||||
test('a lease reads a baseline, holds a value, and gives it back', async () => {
|
||||
sidecar.simulateRestart()
|
||||
|
||||
const baseline = await lease.read()
|
||||
assert.strictEqual(baseline.ok, true)
|
||||
assert.strictEqual(baseline.value, 1.0)
|
||||
|
||||
const until = new Date(Date.now() + 60_000)
|
||||
assert.strictEqual((await lease.apply(2.5, until)).ok, true)
|
||||
assert.strictEqual((await lease.read()).value, 2.5)
|
||||
|
||||
const back = await lease.restore(baseline.value, { expected: 2.5 })
|
||||
assert.strictEqual(back.ok, true)
|
||||
assert.strictEqual((await lease.read()).value, 1.0)
|
||||
})
|
||||
|
||||
test('a lease reports DRIFT rather than overwriting what somebody changed', async () => {
|
||||
sidecar.simulateRestart()
|
||||
|
||||
const baseline = await lease.read()
|
||||
await lease.apply(3, new Date(Date.now() + 60_000))
|
||||
|
||||
// Somebody moved it by hand, mid-event.
|
||||
await lease.apply(4, new Date(Date.now() + 60_000))
|
||||
|
||||
const back = await lease.restore(baseline.value, { expected: 3 })
|
||||
assert.strictEqual(back.ok, true)
|
||||
assert.strictEqual(back.drifted, true, 'restoring over a hand-edit silently is the bug')
|
||||
assert.strictEqual(Number(back.value), 4)
|
||||
})
|
||||
|
||||
test('inForce is a different question from read', async () => {
|
||||
sidecar.simulateRestart()
|
||||
|
||||
// Nothing held: the live value is the default.
|
||||
assert.strictEqual((await lease.inForce()).held, false)
|
||||
|
||||
await lease.apply(2, new Date(Date.now() + 60_000))
|
||||
assert.strictEqual((await lease.inForce()).held, true)
|
||||
|
||||
// A restart takes the hold with it, and `inForce` is the only thing that says
|
||||
// so — `read()` would answer 1.0, which is also what an un-held lease reads.
|
||||
sidecar.simulateRestart()
|
||||
assert.strictEqual((await lease.inForce()).held, false)
|
||||
})
|
||||
|
||||
test('the lease declares a duration bound core can enforce', () => {
|
||||
for (const l of events.LEASES) {
|
||||
assert.ok(l.maxDurationMs > 0, `${l.id} has no duration bound`)
|
||||
assert.strictEqual(typeof l.read, 'function')
|
||||
assert.strictEqual(typeof l.apply, 'function')
|
||||
assert.strictEqual(typeof l.restore, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
// ══ The option source ═════════════════════════════════════════════════════
|
||||
|
||||
test('an option source answers from live data', async () => {
|
||||
const source = events.OPTION_SOURCES.find((s) => s.id === 'examplegame.options.clans')
|
||||
const options = await source.resolve()
|
||||
assert.ok(Array.isArray(options))
|
||||
assert.strictEqual(options.length, CLANS.length)
|
||||
for (const option of options) {
|
||||
assert.strictEqual(typeof option.value, 'string')
|
||||
assert.strictEqual(typeof option.label, 'string')
|
||||
}
|
||||
})
|
||||
|
||||
test('an option source that fails degrades rather than raising', async () => {
|
||||
// Core turns a refusal into a free-text field with a warning; it never blocks
|
||||
// the authoring form. A resolver that threw would be a screen this module's
|
||||
// outage takes away, for a field whose value the operator very often knows.
|
||||
const real = clanDb.listClans
|
||||
clanDb.listClans = async () => { throw new Error('database is down') }
|
||||
try {
|
||||
const source = events.OPTION_SOURCES.find((s) => s.id === 'examplegame.options.clans')
|
||||
assert.deepStrictEqual(await source.resolve(), [])
|
||||
} finally {
|
||||
clanDb.listClans = real
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user