Compare commits
7 Commits
v1.1.0
...
bf9a702cfa
| Author | SHA1 | Date | |
|---|---|---|---|
| bf9a702cfa | |||
| dc13515927 | |||
| cf60932c85 | |||
| 021f191f65 | |||
| 57419111e6 | |||
| 144242fe8f | |||
| c679944181 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It points at `edge` for the length of the Event System window** (org lead, 2026-09-04), and that is the one line here a reader should not tidy back. This module registers event actions from EVENTS_PLAN.md Phase 9, and `api.registerEventActions` exists only from MODULE_API 1.10.0 -- under the previous `main` pin `register()` throws and the module does not load at all, so the job would be red by construction for eight phases and would prove nothing while a real regression hid behind it. Phase 16's cutover re-pins it to `main`, which is the same commit that turns the Integration kit green again.",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "52eac24d170adbeb7cfb06486bc7512da1173ef9",
|
||||
"refName": "edge @ MODULE_API 1.9.0, engagement Phase 11b (website#179)"
|
||||
"ref": "d4516739b43de5cb83b8f0333f8f966280a5632f",
|
||||
"refName": "edge @ MODULE_API 1.10.0, the event module contract (website#189, #190)"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "uo",
|
||||
"name": "Ultima Online",
|
||||
"version": "0.5.0",
|
||||
"coreApi": "^1.9.0",
|
||||
"version": "0.6.0",
|
||||
"coreApi": "^1.10.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
|
||||
@@ -467,6 +467,38 @@ const TEMPLATES = [
|
||||
'{{champsUrl}}',
|
||||
),
|
||||
|
||||
// ── The champion falls (Protocol 6) ─────────────────────────────────────
|
||||
//
|
||||
// The other half of the pair above, and the half the wire could not report
|
||||
// until protocol 6 gave the shard a kind for it. Written as the crier's own
|
||||
// follow-up: the same voice that announced the champion walking is the one
|
||||
// that reports it did not walk far.
|
||||
//
|
||||
// `{{damagerNote}}` is a single-token block, so an unattributed kill renders
|
||||
// the paragraph without it rather than as a sentence with a hole in it.
|
||||
email(
|
||||
'uo.champ.boss-killed',
|
||||
'Champion spawn — the champion falls (town crier)',
|
||||
'uo.champ.boss_killed',
|
||||
'Hear ye — {{bossName}} has fallen',
|
||||
[
|
||||
heading('h', 'Hear ye, hear ye'),
|
||||
text('p1',
|
||||
'{{bossName}} has fallen{{atPlace}}.{{damagerNote}} The altar is quiet again, and it '
|
||||
+ 'will not stay quiet.'),
|
||||
button('cta', 'See the altars', '{{champsUrl}}'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.champ.boss-killed-inapp',
|
||||
'Champion spawn — the champion falls (in-app)',
|
||||
'uo.champ.boss_killed',
|
||||
'{{bossName}} has fallen',
|
||||
'{{bossName}} has fallen{{atPlace}}.{{damagerNote}}',
|
||||
'See the altars',
|
||||
'{{champsUrl}}',
|
||||
),
|
||||
|
||||
// ── A guildmaster of the craft ──────────────────────────────────────────
|
||||
email(
|
||||
'uo.skill.capped',
|
||||
@@ -624,6 +656,14 @@ const TEMPLATES = [
|
||||
const CHANNELS_OWNER = ['email', 'inapp']
|
||||
const CHANNELS_BROADCAST = ['email', 'inapp', 'push']
|
||||
|
||||
// The same two channels as CHANNELS_OWNER and a different reason for them: a
|
||||
// rule that goes to every subscriber but cannot be PUSHED, because push is
|
||||
// keyed on a subscription id and no trigger in this module is also a registered
|
||||
// stream. Same value, different fact — folding them into one constant would lose
|
||||
// the distinction the moment somebody added push to whichever one they read as
|
||||
// "the broadcast-ish list". See `uo.champ.boss_killed`.
|
||||
const CHANNELS_CONTENT = ['email', 'inapp']
|
||||
|
||||
/** In-universe: both bodies are this module's, the digest is core's. */
|
||||
const bodies = (key) => ({
|
||||
email: `uo.${key}`,
|
||||
@@ -863,6 +903,32 @@ const RULES = [
|
||||
cooldown_seconds: 1800,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.champ.boss_killed',
|
||||
name: 'Champion spawn — the champion falls',
|
||||
audience: 'subscribers',
|
||||
// **`CHANNELS_CONTENT`, not `CHANNELS_BROADCAST`** — this is the one rule in
|
||||
// the file that leaves push out, and it is not an oversight.
|
||||
//
|
||||
// Push delivery is keyed on the SUBSCRIPTION id, and a subscription row only
|
||||
// ever exists for an id the preferences screen offered a push toggle for —
|
||||
// which core's catalog grants to registered STREAMS and nothing else. This
|
||||
// module's stream ids (`champ.start`, `idoc.warning`, …) and its trigger ids
|
||||
// (`uo.champ.started`, …) are disjoint sets, so no trigger here can be pushed
|
||||
// through the engagement path at all: the tickle resolves to zero endpoints
|
||||
// while the send log records it delivered.
|
||||
//
|
||||
// That is true of every sibling rule above and is a pre-existing defect, not
|
||||
// one this rule introduces. What this rule declines to do is add a
|
||||
// twenty-first instance of it. See EVENTS_PLAN.md Phase 11a.
|
||||
channels: CHANNELS_CONTENT,
|
||||
template_keys: bodies('champ.boss-killed'),
|
||||
// The same half-hour as its `boss_up` twin, and on the SAME subject — the
|
||||
// spawn — so an altar that pops and is cleared inside the window produces the
|
||||
// walk or the fall, not both.
|
||||
cooldown_seconds: 1800,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.server.up',
|
||||
name: 'Shard — came online',
|
||||
@@ -960,10 +1026,29 @@ const RULES = [
|
||||
},
|
||||
]
|
||||
|
||||
const RULE_GROUPS = [{
|
||||
key: 'triggers-v1',
|
||||
note: 'UO notifications stay off until an operator enables one',
|
||||
rules: RULES,
|
||||
}]
|
||||
// A group is seeded ONCE, under its own settings guard. So a rule appended to an
|
||||
// existing group reaches fresh installs and nothing else: every deployment that
|
||||
// has already stamped `triggers-v1` is done with it forever, and the new rule
|
||||
// would silently never arrive. That is Engagement Phase 11's seed-key finding,
|
||||
// and core applied the same remedy in Events Phase 10 — a NEW key per addition,
|
||||
// never an edit to an old one.
|
||||
//
|
||||
// So protocol 6's `uo.champ.boss_killed` rule ships as its own group rather than
|
||||
// as a twenty-seventh entry above. `RULES` remains the whole declared set, which
|
||||
// is what the "every declared trigger has exactly one rule" invariant reads.
|
||||
const BOSS_KILLED = RULES.filter((r) => r.trigger_id === 'uo.champ.boss_killed')
|
||||
|
||||
const RULE_GROUPS = [
|
||||
{
|
||||
key: 'triggers-v1',
|
||||
note: 'UO notifications stay off until an operator enables one',
|
||||
rules: RULES.filter((r) => !BOSS_KILLED.includes(r)),
|
||||
},
|
||||
{
|
||||
key: 'champ-boss-killed-v1',
|
||||
note: 'The champion-falls notice, added with protocol 6; off like every other',
|
||||
rules: BOSS_KILLED,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { TEMPLATES, RULES, RULE_GROUPS }
|
||||
|
||||
@@ -600,6 +600,42 @@ const COME_ONLINE = [
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Protocol 6, and the reason the kind exists at all. Its first consumer is not
|
||||
// a mail rule but an EVENT PHASE CONDITION: `{ on: 'uo.champ.boss_killed',
|
||||
// where: [...], count: 1 }` is how an author says "move to the next phase when
|
||||
// the boss falls", and a condition is expressed over a trigger firing. That is
|
||||
// also why it is declared here rather than only ingested — a kind nothing
|
||||
// declares is a kind no event can wait on.
|
||||
id: 'uo.champ.boss_killed',
|
||||
label: 'A champion boss was defeated',
|
||||
description: 'Players brought down a champion spawn boss.',
|
||||
kind: 'event',
|
||||
subjectKey: 'spawnSerial',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||||
description: 'The spawn controller, or the boss itself where the shard could not name an altar. Also the cooldown subject.' },
|
||||
{ name: 'bossName', type: 'string', required: true, example: 'Semidar',
|
||||
description: 'The boss that fell.' },
|
||||
{ name: 'category', type: 'string', required: false, example: 'champion',
|
||||
description: 'champion or sea.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570 (Destard)',
|
||||
description: 'Where, already formatted for reading.' },
|
||||
{ name: 'killerName', type: 'string', required: false, example: 'Aldric',
|
||||
description: 'Who struck the last blow, when the shard names one.' },
|
||||
{ name: 'damagerCount', type: 'int', required: false, example: 14,
|
||||
description: 'How many players did damage to it. The names themselves are staff-only and are deliberately not offered here.' },
|
||||
{ name: 'damagerNote', type: 'string', required: false, example: ' 14 players fought it.',
|
||||
description: 'A trailing sentence, LEADING SPACE included, or empty when nobody is credited.' },
|
||||
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||||
description: 'Site-relative path to the champions page.' },
|
||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.server.up',
|
||||
label: 'The shard came online',
|
||||
|
||||
605
server/config/uoEventActions.js
Normal file
605
server/config/uoEventActions.js
Normal file
@@ -0,0 +1,605 @@
|
||||
// ── module-uo's event verbs, wave 1 ────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §F, EVENTS_PLAN.md Phase 9. The first three actions an event author
|
||||
// can put in a step that reach the game, plus the budget dimension that bounds
|
||||
// one of them and the three option sources the atlas answers.
|
||||
//
|
||||
// **Nothing here is new plumbing.** `uoLinkClient` has carried `adminBroadcast`,
|
||||
// `postTownCrier`/`deleteTownCrier` and `postNews`/`deleteNews` since protocol
|
||||
// 2.1; the admin screens have driven all three by hand for months. What this file
|
||||
// adds is the declaration that lets the event engine drive them unattended —
|
||||
// which is a different question, and the reason most of this file is about what
|
||||
// happens when a call does NOT come back.
|
||||
//
|
||||
// ── The three rules that shape every declaration below ─────────────────────
|
||||
//
|
||||
// **1. `budgetMs` must exceed the client's own timeout, or the module never gets
|
||||
// to classify its own failure.** `dispatch.classify()` answers `retry` for a
|
||||
// budget timeout unconditionally and a module cannot override that — the module
|
||||
// is not asked, because it is still awaiting a socket. `uoLinkClient.TIMEOUT_MS`
|
||||
// is 12s and core's `DEFAULT_BUDGET_MS` is 10s, so on default settings core's
|
||||
// deadline fires FIRST on every slow shard and the step is retried. Every action
|
||||
// here therefore declares `budgetMs: 15000`: the client always answers first, and
|
||||
// what the runner acts on is this file's judgement rather than a race.
|
||||
//
|
||||
// That is not a tuning detail. It is the whole of what makes rule 2 true.
|
||||
//
|
||||
// **2. A broadcast is retried, and protocol 6 is what changed that.** Wave 1
|
||||
// shipped `uo.broadcast` answering `retry: false` to everything, because a
|
||||
// retried broadcast was a second announcement to everyone online and nothing on
|
||||
// the wire could make the shard refuse the repeat. A lost announcement was
|
||||
// cheaper than a doubled one, and that was the whole argument.
|
||||
//
|
||||
// Protocol 6 removes its premise. Every write below now carries the step's
|
||||
// `idempotencyKey`; the shard executes a key at most once and answers a repeat
|
||||
// with the ORIGINAL reply rather than re-running it. So a retry of a broadcast
|
||||
// whose acknowledgement was lost cannot announce twice — it collects the answer
|
||||
// the first attempt never delivered. A shard restarting mid-run is now recovered
|
||||
// from rather than written off, which is the case rule 2 used to throw away
|
||||
// knowingly.
|
||||
//
|
||||
// Rule 1 is what keeps this true rather than merely intended: if core's deadline
|
||||
// fired first the module would never be asked, and the retry would be core's
|
||||
// unconditional one — carrying the same key, so still safe, but classified
|
||||
// without the module's judgement.
|
||||
|
||||
// **2a. The one status that is new here.** A repeat arriving while the original
|
||||
// is still in flight on the shard is answered `bridge.busy`, which the sidecar
|
||||
// maps to **425**. It is transient by construction: the work is happening. It is
|
||||
// not in `PERMANENT_STATUSES` and `classify()` falls through to retry, so it
|
||||
// needs no arm of its own — but it is named so that a future tightening of that
|
||||
// list has to decide about it deliberately.
|
||||
//
|
||||
// **3. What a shard restart wipes, `reconcile()` reports gone — and it knows
|
||||
// which restart it was without asking.** There is no "list the town-crier lines"
|
||||
// or "list the news articles" on the wire, and adding one would be protocol work
|
||||
// for a question the module can already answer: a town-crier line and an
|
||||
// event-owned news article both live in shard memory, so a restart is
|
||||
// definitionally the loss of both. `perform()` stamps the shard's `bootId` into
|
||||
// the resource payload and `reconcile()` reports in force exactly the rows whose
|
||||
// stamp still matches. That is correct for BOTH callers — the module's own
|
||||
// `ctx.events.reconcile()` on a changed `bootId`, and core's boot-time sweep,
|
||||
// where the shard may not have restarted at all and answering "all gone" would
|
||||
// abandon live rows.
|
||||
|
||||
const core = require('../core')
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const { classify: classifySidecarWrite } = require('../utils/shardAnnounce')
|
||||
|
||||
const log = core.logger('uo-events')
|
||||
|
||||
// See rule 1 in the header. Above `uoLinkClient.TIMEOUT_MS` (12s), below core's
|
||||
// `MAX_BUDGET_MS` (1h) by a mile.
|
||||
const BUDGET_MS = 15000
|
||||
|
||||
// The sidecar's own caps, mirrored from the admin routes that already validate
|
||||
// against them (`admin/uoLink.router.js` for the crier, `admin/shard.router.js`
|
||||
// for the broadcast). Pre-checked here so an over-long line is a refusal a DRY
|
||||
// RUN can show the author, rather than a 400 mid-run.
|
||||
const MAX_BROADCAST_LEN = 300
|
||||
const MAX_CRIER_LINES = 8
|
||||
const MAX_CRIER_LINE_LEN = 200
|
||||
const MAX_CRIER_DURATION_SEC = 86400
|
||||
const MAX_NEWS_TITLE = 120
|
||||
const MAX_NEWS_BODY = 900
|
||||
|
||||
// How many options one source will answer with. Real UO facets carry a few
|
||||
// hundred regions and landmarks and ~800 constructible creature types, so this is
|
||||
// comfortably above the data rather than a guess at it — and a deployment that
|
||||
// exceeds it gets a log line naming the source and the counts, because a dropdown
|
||||
// that silently omits the landmark an author is looking for is the defect this
|
||||
// bound would otherwise introduce.
|
||||
const MAX_OPTIONS = 2000
|
||||
|
||||
/**
|
||||
* The id both keyed verbs post under.
|
||||
*
|
||||
* The step's idempotency key is `sha256(runId|stepId)` truncated to 40 hex — a
|
||||
* function of identity and never of attempt — so a retry re-posts the SAME id and
|
||||
* the sidecar replaces rather than stacks. That is the property that makes the
|
||||
* crier and the news gump safe to retry and the broadcast not.
|
||||
*
|
||||
* **The `evt-` prefix is load-bearing for news.** `newsGump.js` posts articles
|
||||
* under the bare website post id (`String(post.id)`) and `reassertAll()` re-pushes
|
||||
* that whole set on every sidecar reconnect. An event article numbered into the
|
||||
* same space would be a collision with a post — silently, and in whichever
|
||||
* direction wrote last. 4 + 40 characters, inside the sidecar's 64-char cap.
|
||||
*/
|
||||
const resourceId = (idempotencyKey) => `evt-${idempotencyKey}`
|
||||
|
||||
/** The shard boot this write belongs to, or null when nothing has connected yet. */
|
||||
async function currentBootId() {
|
||||
try {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
return config.bootId || null
|
||||
} catch (err) {
|
||||
// Never fatal to a world write. A missing stamp means `reconcile()` cannot
|
||||
// vouch for the row, which leaves core believing its own ledger — the
|
||||
// pre-Phase-8 behaviour, and the right way to be wrong.
|
||||
log.warn('could not read the shard boot id for an event resource', { error: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Statuses that will never succeed however many times they are tried: a data
|
||||
// refusal, a bad token, a switched-off write plane, a protocol mismatch. Named
|
||||
// here rather than folded into `shardAnnounce.classify` because 403 is reachable
|
||||
// only from the `/admin/*` verbs — the announce leg posts to the town crier,
|
||||
// which the admin write plane does not gate — and widening a shared classifier
|
||||
// for a case its own caller cannot produce is how a shared rule stops being one.
|
||||
const PERMANENT_STATUSES = new Set([400, 401, 403, 404, 409])
|
||||
|
||||
/**
|
||||
* What the shard actually said, in its own words.
|
||||
*
|
||||
* **The sidecar explains its refusals and `legError` drops the explanation**, and
|
||||
* this was worth its own helper the moment an event started making these calls
|
||||
* unattended. A `403` body reads `{"reason":"admin write plane disabled"}`;
|
||||
* `legError` looks for `data.message`, finds nothing, and falls back to "sidecar
|
||||
* responded 403". For a staff member clicking a button that is survivable — they
|
||||
* know what they just switched off. For an event that ran at four in the morning,
|
||||
* the run log is the only place anyone will ever learn why, and "403" is not an
|
||||
* answer an operator can act on.
|
||||
*/
|
||||
function sidecarReason(result, what) {
|
||||
const data = (result && result.data) || {}
|
||||
return data.reason || data.message || (result && result.error) || `the shard refused the ${what}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidecar's answer, as an event outcome.
|
||||
*
|
||||
* **The announce leg's classification, not a second opinion.** `shardAnnounce`
|
||||
* already decides what each status from this transport means — 400 a data
|
||||
* problem, 401/409 a config problem, everything else transient — and it decides
|
||||
* it about the same sidecar over the same client. Two copies of that judgement is
|
||||
* how the two drift, which is the argument `core.announce` makes for deferring to
|
||||
* a leg's own `classify()`.
|
||||
*/
|
||||
function sidecarFailure(result, what) {
|
||||
const { outcome } = classifySidecarWrite(result)
|
||||
const permanent = PERMANENT_STATUSES.has(result && result.status)
|
||||
return {
|
||||
ok: false,
|
||||
retry: outcome === 'retry' && !permanent,
|
||||
error: sidecarReason(result, what),
|
||||
}
|
||||
}
|
||||
|
||||
/** Split an authored text block into crier lines, and say why it is not one. */
|
||||
function crierLines(raw) {
|
||||
const lines = String(raw == null ? '' : raw)
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
if (!lines.length) return { ok: false, error: 'the message is empty' }
|
||||
if (lines.length > MAX_CRIER_LINES) {
|
||||
return { ok: false, error: `the criers carry ${MAX_CRIER_LINES} lines and this is ${lines.length}` }
|
||||
}
|
||||
const over = lines.find((l) => l.length > MAX_CRIER_LINE_LEN)
|
||||
if (over) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `a crier line is capped at ${MAX_CRIER_LINE_LEN} characters and "${over.slice(0, 40)}…" is ${over.length}`,
|
||||
}
|
||||
}
|
||||
return { ok: true, lines }
|
||||
}
|
||||
|
||||
// ── Budgets ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One dimension, and only `uo.broadcast` spends it (org lead, 2026-09-04). A run
|
||||
// that broadcasts forty times is the spam failure mode, and a per-run cap is the
|
||||
// only thing standing between an authoring mistake and every player online. The
|
||||
// two keyed verbs get none: they are posted under a run-scoped id and a repeat
|
||||
// REPLACES, so the thing a cap would guard against does not exist for them.
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'uo.broadcasts',
|
||||
label: 'Global broadcasts',
|
||||
unit: 'broadcasts',
|
||||
description: 'System messages this run may put in front of everyone online.',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTIONS = [
|
||||
{
|
||||
id: 'uo.broadcast',
|
||||
label: 'Broadcast to everyone online',
|
||||
description:
|
||||
'Puts one system message in front of every player currently logged in. Sent once and never retried — a repeat would be a second announcement, and until the shard can refuse a duplicate there is no way to take one back.',
|
||||
|
||||
// Nothing in the world changes and nothing is created; a message goes out.
|
||||
// Same class as `core.announce`, and for the same reason — which also gives
|
||||
// it `skip` as its default disposition, so a run does not stop over an
|
||||
// announcement that did not go out.
|
||||
risk: 'notify',
|
||||
// There is no undo, and declaring `ledger` would put a row in the cleanup
|
||||
// ledger that teardown could never resolve.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
|
||||
cost: () => ({ 'uo.broadcasts': 1 }),
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'The gates of Britain open at dusk. Gather at the bank.',
|
||||
description: `The message, up to ${MAX_BROADCAST_LEN} characters.`,
|
||||
},
|
||||
{
|
||||
name: 'hue',
|
||||
type: 'int',
|
||||
required: false,
|
||||
example: 1153,
|
||||
description: 'UO colour id for the message. Left out, the shard uses its system colour.',
|
||||
},
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const text = String(params.text == null ? '' : params.text).trim()
|
||||
// Checked here rather than left to the sidecar's 400, so the DRY RUN shows
|
||||
// the author the refusal — which is the whole point of having one.
|
||||
if (!text) return { ok: false, retry: false, error: 'the message is empty' }
|
||||
if (text.length > MAX_BROADCAST_LEN) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `a broadcast is capped at ${MAX_BROADCAST_LEN} characters and this is ${text.length}`,
|
||||
}
|
||||
}
|
||||
if (verify) return { ok: true }
|
||||
|
||||
// `event:<runId>` (org lead, 2026-09-04). The shard records an actor on
|
||||
// every staff write and echoes it back as an `admin.audit` event, so this
|
||||
// is what an operator reads in the game's own audit trail afterwards. No
|
||||
// staff member pressed a button — attributing it to one would be a false
|
||||
// record — and the run id is the thing that makes the line actionable.
|
||||
const result = await uoLinkClient.adminBroadcast({
|
||||
actor: `event:${runId}`,
|
||||
text,
|
||||
hue: params.hue === undefined || params.hue === null ? undefined : Number(params.hue),
|
||||
// Protocol 6, and the line rule 2 said would change. The key is the
|
||||
// step's, so every attempt at this step carries the same one and the
|
||||
// shard refuses the repeat — which is what makes the retry below safe to
|
||||
// ask for at all.
|
||||
idempotencyKey,
|
||||
})
|
||||
if (result.ok) return { ok: true }
|
||||
|
||||
// **A transient failure is now retried**, where wave 1 gave up on it. What
|
||||
// used to make a retry unsafe was that the shard could not tell a repeat
|
||||
// from a fresh command; it can now, so a 503 from a shard that is merely
|
||||
// restarting is recovered from instead of being written off.
|
||||
//
|
||||
// The classification itself is `sidecarFailure`'s — the announce leg's own
|
||||
// judgement about this transport, deferred to rather than second-guessed,
|
||||
// exactly as the two keyed verbs below already do. That this action now
|
||||
// uses the SAME helper as its siblings, instead of a hand-rolled variant
|
||||
// that forced every outcome terminal, is most of the change here.
|
||||
return sidecarFailure(result, 'broadcast')
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'uo.towncrier.post',
|
||||
label: 'Post to the town criers',
|
||||
description:
|
||||
'Puts up to eight lines in the mouths of the town criers for a set time, and takes them down again when the event ends.',
|
||||
|
||||
// `notify` is about what a FAILURE costs — nothing is half-changed and the
|
||||
// run should carry on — while `ledger` is about what SUCCESS leaves behind.
|
||||
// The two are independent questions and this is the combination where that
|
||||
// shows: an announcement that can be withdrawn.
|
||||
risk: 'notify',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
|
||||
params: [
|
||||
{
|
||||
// **One text block, not a list, because the param vocabulary has no
|
||||
// array type** (`VARIABLE_TYPES` is string/int/float/boolean/datetime/url).
|
||||
// Splitting on newlines is the honest encoding of eight short lines in a
|
||||
// textarea, and the caps are checked before anything is sent.
|
||||
name: 'lines',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'Hear ye! The Britain gates open at dusk.\nSeek the herald by the bank.',
|
||||
description: `One line per newline. Up to ${MAX_CRIER_LINES} lines of ${MAX_CRIER_LINE_LEN} characters.`,
|
||||
},
|
||||
{
|
||||
name: 'durationMinutes',
|
||||
type: 'int',
|
||||
required: false,
|
||||
example: 60,
|
||||
description: 'How long the criers keep saying it. Left out, the shard keeps it for an hour.',
|
||||
},
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const parsed = crierLines(params.lines)
|
||||
if (!parsed.ok) return { ok: false, retry: false, error: parsed.error }
|
||||
|
||||
let durationSec
|
||||
if (params.durationMinutes !== undefined && params.durationMinutes !== null) {
|
||||
const minutes = Number(params.durationMinutes)
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) {
|
||||
return { ok: false, retry: false, error: `"${params.durationMinutes}" is not a number of minutes` }
|
||||
}
|
||||
durationSec = Math.min(Math.round(minutes * 60), MAX_CRIER_DURATION_SEC)
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const id = resourceId(idempotencyKey)
|
||||
const bootId = await currentBootId()
|
||||
// Protocol 6. This verb was already safe to retry — a repeat under the same
|
||||
// `id` REPLACES the crier entry rather than stacking a second one — so the
|
||||
// key buys no new safety here. It is sent because it costs nothing and
|
||||
// makes the retry a no-op on the shard rather than a redundant world write,
|
||||
// and because a write plane where only some commands are keyed is one
|
||||
// somebody will later have to reason about per verb.
|
||||
const result = await uoLinkClient.postTownCrier({
|
||||
id,
|
||||
lines: parsed.lines,
|
||||
durationSec,
|
||||
idempotencyKey,
|
||||
})
|
||||
if (!result.ok) return sidecarFailure(result, 'town-crier post')
|
||||
|
||||
// The stamp rule 3 rests on. `runId` rides along so a row read out of the
|
||||
// ledger says which run put it up without a join.
|
||||
return { ok: true, resources: [{ kind: 'towncrier', ref: id, payload: { bootId, runId } }] }
|
||||
},
|
||||
|
||||
async revert({ resources }) {
|
||||
const failed = []
|
||||
for (const resource of resources) {
|
||||
const result = await uoLinkClient.deleteTownCrier(resource.ref)
|
||||
// §L: "gone, and that is fine" is a successful revert. A crier line whose
|
||||
// duration simply ran out is a 404, and it is the outcome we wanted.
|
||||
if (!result.ok && result.status !== 404) failed.push(resource.ref)
|
||||
}
|
||||
if (!failed.length) return { ok: true }
|
||||
return { ok: true, failed }
|
||||
},
|
||||
|
||||
reconcile: reconcileByBootId,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'uo.news.post',
|
||||
label: 'Post an article to the news gump',
|
||||
description:
|
||||
"Puts an article in the in-game Town Cryer news window for the life of the event, and pulls it when the event ends. Separate from the site's own news posts, which sync there on their own.",
|
||||
|
||||
risk: 'notify',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'The Britannian Midsummer Fair',
|
||||
description: `The article heading, up to ${MAX_NEWS_TITLE} characters.`,
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'Merchants from every city gather in Britain for three days of trade and contest.',
|
||||
description: `The article, up to ${MAX_NEWS_BODY} characters. Plain text; the gump renders a small HTML subset and this is wrapped for it.`,
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
type: 'url',
|
||||
required: false,
|
||||
example: 'https://example.com/site/events',
|
||||
description: "The article's \"more info\" link. Left out, the gump shows no link.",
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'int',
|
||||
required: false,
|
||||
example: 5013,
|
||||
description: 'A shard art id to illustrate the article. Left out, the sidecar uses a neutral scroll.',
|
||||
},
|
||||
{
|
||||
name: 'announce',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
example: true,
|
||||
description: 'Whether the criers proclaim the title when it goes up. Left out, they do.',
|
||||
},
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const title = String(params.title == null ? '' : params.title).replace(/\s+/g, ' ').trim()
|
||||
const body = String(params.body == null ? '' : params.body).trim()
|
||||
if (!title) return { ok: false, retry: false, error: 'the article has no title' }
|
||||
if (title.length > MAX_NEWS_TITLE) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `a news title is capped at ${MAX_NEWS_TITLE} characters and this is ${title.length}`,
|
||||
}
|
||||
}
|
||||
if (!body) return { ok: false, retry: false, error: 'the article has no body' }
|
||||
if (body.length > MAX_NEWS_BODY) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `a news body is capped at ${MAX_NEWS_BODY} characters and this is ${body.length}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const id = resourceId(idempotencyKey)
|
||||
const bootId = await currentBootId()
|
||||
const result = await uoLinkClient.postNews({
|
||||
id,
|
||||
title,
|
||||
// The same gump-HTML shape `newsGump.buildArticle` uses, so an event
|
||||
// article and a site article read alike in the window they share.
|
||||
body: `<CENTER>${title}</CENTER><BR><BR>${body}`,
|
||||
image:
|
||||
params.image === undefined || params.image === null ? undefined : Number(params.image),
|
||||
url: params.url || undefined,
|
||||
announce: params.announce === undefined || params.announce === null ? true : Boolean(params.announce),
|
||||
// Protocol 6, for the same reason the crier carries one — except that
|
||||
// here it does buy something. `announce: true` makes the criers proclaim
|
||||
// the article's title when it is posted, so a re-post under the same id
|
||||
// replaces the article silently but proclaims it AGAIN. The key stops the
|
||||
// second proclamation, which was the one part of this verb that was never
|
||||
// as idempotent as its `id` made it look.
|
||||
idempotencyKey,
|
||||
})
|
||||
if (!result.ok) return sidecarFailure(result, 'news article')
|
||||
|
||||
return { ok: true, resources: [{ kind: 'news', ref: id, payload: { bootId, runId } }] }
|
||||
},
|
||||
|
||||
async revert({ resources }) {
|
||||
const failed = []
|
||||
for (const resource of resources) {
|
||||
const result = await uoLinkClient.deleteNews(resource.ref)
|
||||
if (!result.ok && result.status !== 404) failed.push(resource.ref)
|
||||
}
|
||||
if (!failed.length) return { ok: true }
|
||||
return { ok: true, failed }
|
||||
},
|
||||
|
||||
reconcile: reconcileByBootId,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Which of these does the shard still have? — answered from the boot stamp.
|
||||
*
|
||||
* Shared by both keyed verbs because the answer has the same shape for both:
|
||||
* shard memory, lost on restart. See rule 3 in the header for why this needs no
|
||||
* round trip and why it must not simply answer "all gone".
|
||||
*
|
||||
* **A row with no stamp is reported IN FORCE.** It was written by a build before
|
||||
* the stamp existed, or by a `perform()` whose config read hiccuped, and "I do not
|
||||
* know" must never be read as "it is gone" — core orphans exactly what this omits,
|
||||
* and an orphaned row is one teardown will never try to take back.
|
||||
*/
|
||||
async function reconcileByBootId({ resources }) {
|
||||
const bootId = await currentBootId()
|
||||
// Nothing has connected since this process came up, so there is no current boot
|
||||
// to compare against. Declining to answer leaves core believing its ledger.
|
||||
if (!bootId) return { ok: false, error: 'the shard has not identified itself since boot' }
|
||||
const inForce = resources
|
||||
.filter((r) => {
|
||||
const stamped = r.payload && r.payload.bootId
|
||||
return !stamped || stamped === bootId
|
||||
})
|
||||
.map((r) => r.ref)
|
||||
return { ok: true, inForce }
|
||||
}
|
||||
|
||||
// ── Option sources ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Answered from the spawn atlas, which is derived from the operator's own ServUO
|
||||
// tree on every boot and stored — so these resolve with the shard down, which is
|
||||
// the property that makes them safe to put behind an authoring form.
|
||||
//
|
||||
// **Wave 1's three verbs use none of them.** They ship here rather than with
|
||||
// their consumers in Phase 12 (org lead, 2026-09-04) because they cost nothing
|
||||
// new, and Phase 12 is a five-repo protocol bump that should not also be carrying
|
||||
// its first atlas plumbing. `/admin/events/catalog/options/:sourceId` exercises
|
||||
// them today.
|
||||
//
|
||||
// **A place is named `facet/name`, not `name`.** Two facets both have a Britain,
|
||||
// and a value that can name two different places is a value a Phase 12 step
|
||||
// cannot act on. The author reads the label and the group; the stored value is
|
||||
// unambiguous.
|
||||
|
||||
/** Bound one source's answer, and say so when the atlas is bigger than the bound. */
|
||||
function bounded(rows, sourceId) {
|
||||
if (rows.length <= MAX_OPTIONS) return rows
|
||||
log.warn('option source truncated — the atlas is larger than the dropdown bound', {
|
||||
source: sourceId,
|
||||
available: rows.length,
|
||||
served: MAX_OPTIONS,
|
||||
})
|
||||
return rows.slice(0, MAX_OPTIONS)
|
||||
}
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'uo.options.regions',
|
||||
label: 'Regions',
|
||||
description: "Named regions from the shard's own map definitions, by facet.",
|
||||
async resolve() {
|
||||
const rows = await shardAtlas.listRegions()
|
||||
return bounded(rows, 'uo.options.regions').map((r) => ({
|
||||
value: `${r.facet}/${r.name}`,
|
||||
label: r.name,
|
||||
group: r.facet,
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'uo.options.landmarks',
|
||||
label: 'Landmarks',
|
||||
description: 'Named points of interest — towns, dungeons, moongates — by facet.',
|
||||
async resolve() {
|
||||
const rows = await shardAtlas.listLandmarks()
|
||||
return bounded(rows, 'uo.options.landmarks').map((r) => ({
|
||||
value: `${r.facet}/${r.name}`,
|
||||
label: r.name,
|
||||
// The atlas's own grouping where it has one, the facet otherwise — so a
|
||||
// shard whose landmark file carries no groups still gets a usable
|
||||
// dropdown rather than one flat list of several hundred names.
|
||||
group: r.group || r.facet,
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'uo.options.creatures',
|
||||
label: 'Creatures',
|
||||
description: 'Creature types the shard actually spawns, from the spawn atlas.',
|
||||
async resolve() {
|
||||
// The slug is unique by construction, so unlike a place a creature needs no
|
||||
// qualifier: it is the same type wherever it spawns.
|
||||
const { creatures } = await shardAtlas.searchCreatures({ limit: MAX_OPTIONS })
|
||||
return creatures.map((c) => ({ value: c.slug, label: c.name }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
ACTIONS,
|
||||
BUDGETS,
|
||||
OPTION_SOURCES,
|
||||
// Exported for the tests, which assert the caps and the classification rules
|
||||
// against the same constants the declarations use rather than against literals
|
||||
// that could drift from them.
|
||||
BUDGET_MS,
|
||||
MAX_BROADCAST_LEN,
|
||||
MAX_CRIER_LINES,
|
||||
MAX_CRIER_LINE_LEN,
|
||||
MAX_NEWS_TITLE,
|
||||
MAX_NEWS_BODY,
|
||||
MAX_OPTIONS,
|
||||
PERMANENT_STATUSES,
|
||||
sidecarReason,
|
||||
resourceId,
|
||||
crierLines,
|
||||
reconcileByBootId,
|
||||
}
|
||||
@@ -104,7 +104,16 @@ module.exports = {
|
||||
// do with a storage failure of core's. `inbox.push` additionally does not report
|
||||
// "the user has this switched off", because a module that could see that would
|
||||
// be a module that could enumerate people's preferences one write at a time.
|
||||
events: { emit: (...args) => need().events.emit(...args) },
|
||||
events: {
|
||||
emit: (...args) => need().events.emit(...args),
|
||||
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). "Ask every action of mine which
|
||||
// of its ledgered resources the game still has." Core cannot know when to
|
||||
// ask -- it has no concept of the game being up -- so the module says when,
|
||||
// and `shardIngest` says it on a changed `bootId`. Fire-and-forget like
|
||||
// `emit`, and for the same reason: core owns what happens next and there is
|
||||
// nothing a game-event handler could correctly do with the answer.
|
||||
reconcile: (...args) => need().events.reconcile(...args),
|
||||
},
|
||||
inbox: { push: (...args) => need().inbox.push(...args) },
|
||||
secretBox: {
|
||||
encrypt: (...args) => need().secretBox.encrypt(...args),
|
||||
|
||||
@@ -47,6 +47,7 @@ module.exports = function register(ctx, api) {
|
||||
const shardTriggers = require('./config/shardTriggers')
|
||||
const shardAudiences = require('./config/shardAudiences')
|
||||
const engagementSeeds = require('./config/engagementSeeds')
|
||||
const uoEventActions = require('./config/uoEventActions')
|
||||
const townCrierLeg = require('./utils/shardAnnounce')
|
||||
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
||||
const guildCommand = require('./commands/guild.command')
|
||||
@@ -157,6 +158,25 @@ const engagementSeeds = require('./config/engagementSeeds')
|
||||
// either.
|
||||
api.registerSlashCommands([guildCommand])
|
||||
|
||||
// The event contract (MODULE_API 1.10.0, EVENTS.md F, EVENTS_PLAN.md Phase 9).
|
||||
// Three verbs an event author can put in a step, the one budget dimension that
|
||||
// bounds a broadcast, and the three option sources the spawn atlas answers.
|
||||
//
|
||||
// **All of it is optional, by the contract's own posture.** A deployment
|
||||
// without this module still has an event engine that can announce, wait, cue a
|
||||
// human and publish results; what these add is the ability for an event to
|
||||
// reach the GAME. Nothing here is a precondition for anything of core's.
|
||||
//
|
||||
// The wave is deliberately the verbs that need no protocol change: the write
|
||||
// plane they use has existed since protocol 2.1 and the admin screens have
|
||||
// driven it by hand for months. The world verbs -- creatures, gates, leases --
|
||||
// wait for Phase 11 to put an idempotency key and a lease deadline on the wire,
|
||||
// because a world write core cannot prove ran exactly once is not one this
|
||||
// module is willing to make unattended.
|
||||
api.registerEventBudgets(uoEventActions.BUDGETS)
|
||||
api.registerEventActions(uoEventActions.ACTIONS)
|
||||
api.registerEventOptionSources(uoEventActions.OPTION_SOURCES)
|
||||
|
||||
api.onBoot(boot.onBoot)
|
||||
api.onShutdown(boot.onShutdown)
|
||||
|
||||
@@ -166,5 +186,6 @@ const engagementSeeds = require('./config/engagementSeeds')
|
||||
streams: shardStreams.STREAMS.length,
|
||||
triggers: shardTriggers.TRIGGERS.length,
|
||||
audiences: shardAudiences.AUDIENCES.length,
|
||||
eventActions: uoEventActions.ACTIONS.length,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function fakeCtx(overrides = {}) {
|
||||
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
|
||||
// contract — a module gets no delivery answer back, deliberately — so the
|
||||
// spies return undefined rather than a promise, which is what core does.
|
||||
events: { emit: spy(undefined) },
|
||||
events: { emit: spy(undefined), reconcile: spy(undefined) },
|
||||
inbox: { push: spy(undefined) },
|
||||
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
|
||||
middleware: {
|
||||
@@ -104,6 +104,9 @@ function fakeApi() {
|
||||
slashCommands: [],
|
||||
triggers: null,
|
||||
audiences: null,
|
||||
eventActions: null,
|
||||
eventBudgets: null,
|
||||
eventOptionSources: null,
|
||||
hooks: {},
|
||||
}
|
||||
const called = new Set()
|
||||
@@ -134,6 +137,12 @@ function fakeApi() {
|
||||
// and merging two calls would make "which group is this rule in" — the
|
||||
// question the one-shot seed guard answers — unanswerable.
|
||||
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
||||
// MODULE_API 1.10.0 (EVENTS.md F, EVENTS_PLAN.md Phases 7 and 9). `once` on
|
||||
// all three, matching core: it stages a registrant's whole batch and applies
|
||||
// it as one, so a second call is a module changing its mind mid-register().
|
||||
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
||||
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
|
||||
@@ -96,8 +96,10 @@ test('the seventeen in-universe families have both channels; the nine plain ones
|
||||
// letter from anybody.
|
||||
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
|
||||
}
|
||||
assert.equal(bespoke, 17)
|
||||
assert.equal(seeds.TEMPLATES.length, 34)
|
||||
// Eighteen since protocol 6: the champion FALLS, in the same crier's voice as
|
||||
// the champion walking, because they are one story told in two mails.
|
||||
assert.equal(bespoke, 18)
|
||||
assert.equal(seeds.TEMPLATES.length, 36)
|
||||
})
|
||||
|
||||
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
|
||||
@@ -222,7 +224,7 @@ test('every declared fragment carries an example that shows its own shape', () =
|
||||
// The `example` is what the template editor previews and test-sends with, so a
|
||||
// trailing fragment whose example omits the leading space teaches an author the
|
||||
// wrong thing about where to put one.
|
||||
const TRAILING = ['slainBy', 'atPlace', 'inSuccessionTo', 'candidateNote']
|
||||
const TRAILING = ['slainBy', 'atPlace', 'inSuccessionTo', 'candidateNote', 'damagerNote']
|
||||
for (const t of TRIGGERS) {
|
||||
for (const v of t.variables.filter((x) => TRAILING.includes(x.name))) {
|
||||
assert.ok(v.example.startsWith(' '), `${t.id}.${v.name} example leads with its space`)
|
||||
@@ -236,7 +238,17 @@ test('one rule group, and appending to it later would reach fresh installs only'
|
||||
// A group is seeded ONCE under its own settings guard, which is 11a's seed-key
|
||||
// finding as a mechanism. This assertion exists so that adding a twenty-sixth
|
||||
// rule has to edit a test whose name says what appending costs.
|
||||
assert.equal(seeds.RULE_GROUPS.length, 1)
|
||||
// TWO groups since protocol 6, and the second one is this test's whole point
|
||||
// made concrete: `uo.champ.boss_killed` could not be appended to `triggers-v1`,
|
||||
// because a deployment that has already stamped that key would never have
|
||||
// received it. A new rule gets a new key.
|
||||
assert.equal(seeds.RULE_GROUPS.length, 2)
|
||||
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
|
||||
assert.equal(seeds.RULE_GROUPS[0].rules.length, 26)
|
||||
assert.equal(seeds.RULE_GROUPS[1].key, 'champ-boss-killed-v1')
|
||||
assert.deepEqual(seeds.RULE_GROUPS[1].rules.map((r) => r.trigger_id), ['uo.champ.boss_killed'])
|
||||
// No rule belongs to two groups, and between them they are the whole set.
|
||||
const grouped = seeds.RULE_GROUPS.flatMap((g) => g.rules.map((r) => r.trigger_id))
|
||||
assert.equal(new Set(grouped).size, grouped.length)
|
||||
assert.deepEqual([...grouped].sort(), seeds.RULES.map((r) => r.trigger_id).sort())
|
||||
})
|
||||
|
||||
@@ -53,6 +53,21 @@ test('registers exactly what module.json declares', () => {
|
||||
|
||||
assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
|
||||
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier'])
|
||||
|
||||
// The event contract (MODULE_API 1.10.0, EVENTS_PLAN.md Phase 9). Asserted
|
||||
// here rather than only in the actions' own suite because registration is the
|
||||
// half that can silently not happen: a declaration file nothing calls is a
|
||||
// deployment whose event authors simply never see the verbs, with no error
|
||||
// anywhere.
|
||||
assert.deepStrictEqual(
|
||||
api.record.eventActions.map((a) => a.id).sort(),
|
||||
['uo.broadcast', 'uo.news.post', 'uo.towncrier.post'],
|
||||
)
|
||||
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), ['uo.broadcasts'])
|
||||
assert.deepStrictEqual(
|
||||
api.record.eventOptionSources.map((s) => s.id).sort(),
|
||||
['uo.options.creatures', 'uo.options.landmarks', 'uo.options.regions'],
|
||||
)
|
||||
assert.ok(api.record.streams.length > 0)
|
||||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||||
|
||||
@@ -30,7 +30,11 @@ const one = (event) => {
|
||||
// ── The catalogue itself ───────────────────────────────────────────────────
|
||||
|
||||
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
|
||||
assert.equal(TRIGGERS.length, 26)
|
||||
// 27 since protocol 6: `uo.champ.boss_killed` joins the twenty-six §8.6 named.
|
||||
// It is not one of the four carve-outs below being reinstated — it is a row the
|
||||
// catalogue could not have, because until protocol 6 the wire had no kind for a
|
||||
// boss defeat and the inference from `champ.update` was not good enough to mail.
|
||||
assert.equal(TRIGGERS.length, 27)
|
||||
// The four rows that do NOT ship, each with its reason recorded in §8.6. This
|
||||
// assertion is the guard on the carve-outs: adding one back is a decision, and
|
||||
// a decision should have to edit a test that says so.
|
||||
@@ -113,6 +117,10 @@ test('every url variable a body can interpolate is actually SUPPLIED', () => {
|
||||
'uo.election.opened': [city(), city({ electionPhase: 'nominate', autoPickAt: inHours(48), candidates: 2 })],
|
||||
'uo.champ.started': [champ({ active: false }), champ({ active: true })],
|
||||
'uo.champ.boss_up': [champ({ bossUp: false }), champ({ bossUp: true })],
|
||||
// Protocol 6. A single frame, unlike its two neighbours: a defeat is an
|
||||
// EVENT on the wire rather than a change spotted between two snapshots, which
|
||||
// is the whole reason the kind was worth a protocol bump.
|
||||
'uo.champ.boss_killed': bossKilled(),
|
||||
'uo.server.up': { kind: 'server.hello', shard: 'Rig' },
|
||||
'uo.server.down': { kind: 'server.shutdown' },
|
||||
'uo.page.new': { kind: 'page.new', type: 'Bug', sender: { name: 'Darrow' }, message: 'stuck' },
|
||||
@@ -320,6 +328,21 @@ test('the pre-decision attempt kind is not mapped at all', () => {
|
||||
|
||||
const champ = (over) => ({ kind: 'champ.update', serial: '0x40012345', name: 'Abyss', category: 'champion', map: 'Felucca', x: 5187, y: 570, ...over })
|
||||
|
||||
// Protocol 6. The spawn serial matches `champ`'s, so the pair can be walked as
|
||||
// one altar's story: the boss goes up, then it comes down.
|
||||
const bossKilled = (over) => ({
|
||||
kind: 'champ.boss.killed',
|
||||
serial: '0x40012345',
|
||||
bossSerial: '0x901', category: 'champion', boss: 'Semidar', bossType: 'Semidar',
|
||||
map: 'Felucca', x: 5187, y: 570, region: 'Destard',
|
||||
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
|
||||
damagers: [
|
||||
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true, damage: 900 },
|
||||
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
|
||||
],
|
||||
...over,
|
||||
})
|
||||
|
||||
test('a first sighting is never a transition — a reconnect is not twenty spawns starting', () => {
|
||||
assert.deepEqual(ids(champ({ active: true })), [])
|
||||
assert.deepEqual(ids(champ({ active: true })), []) // still no change
|
||||
@@ -339,6 +362,64 @@ test('champ.remove forgets the spawn, so its next appearance is a first sighting
|
||||
assert.deepEqual(ids(champ({ active: true })), [])
|
||||
})
|
||||
|
||||
// ── champ.boss.killed (Protocol 6) ─────────────────────────────────────────
|
||||
|
||||
test('a defeat fires on the frame itself, with no baseline to compare against', () => {
|
||||
// Unlike its two neighbours above. `champ.update` is a SNAPSHOT, so a first
|
||||
// sighting can never be a transition; a defeat is an event, so a first sighting
|
||||
// is exactly the thing being reported.
|
||||
const hit = one(bossKilled())
|
||||
assert.equal(hit.triggerId, 'uo.champ.boss_killed')
|
||||
assert.equal(hit.data.bossName, 'Semidar')
|
||||
assert.equal(hit.data.killerName, 'Aldric')
|
||||
assert.equal(hit.data.damagerCount, 2)
|
||||
assert.equal(hit.data.damagerNote, ' 2 players fought it.')
|
||||
assert.equal(hit.data.location, 'Felucca 5187, 570 (Destard)')
|
||||
})
|
||||
|
||||
test('the subject is the SPAWN, so boss_up and boss_killed share one cooldown subject', () => {
|
||||
map(champ({ active: true, bossUp: false }))
|
||||
const up = one(champ({ active: true, bossUp: true }))
|
||||
const down = one(bossKilled())
|
||||
assert.equal(up.triggerId, 'uo.champ.boss_up')
|
||||
assert.equal(down.data.spawnSerial, up.data.spawnSerial)
|
||||
})
|
||||
|
||||
test('a defeat the shard could not attribute to an altar stands on the boss itself', () => {
|
||||
// The sweep learns which altar a champion belongs to; a boss that popped and
|
||||
// died between two sweeps arrives with no `serial`. A subject that exists once
|
||||
// is all a cooldown needs, so the boss's own serial stands in rather than the
|
||||
// firing being dropped.
|
||||
const hit = one(bossKilled({ serial: undefined }))
|
||||
assert.equal(hit.data.spawnSerial, '0x901')
|
||||
})
|
||||
|
||||
test('a defeat clears the tracker, so the next boss on that altar is a transition again', () => {
|
||||
map(champ({ active: true, bossUp: false }))
|
||||
map(champ({ active: true, bossUp: true })) // fires boss_up
|
||||
map(bossKilled())
|
||||
// Without the tracker reset this would emit nothing: the tracker would still
|
||||
// believe a boss is up, so the next one would not look like a change.
|
||||
assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up'])
|
||||
})
|
||||
|
||||
test('the damage TABLE never becomes trigger data, only its size', () => {
|
||||
// `damagers` is `staff` in the visibility config. A trigger variable is
|
||||
// interpolated into mail an operator may address to every subscriber, so a
|
||||
// damager name reaching `data` would undo that field rule one layer up.
|
||||
const hit = one(bossKilled())
|
||||
const rendered = JSON.stringify(hit.data)
|
||||
assert.equal(rendered.includes('Bran'), false, 'no damager name reaches the data')
|
||||
assert.equal(rendered.includes('seed_003'), false, 'no damager account reaches the data')
|
||||
assert.equal(hit.data.damagers, undefined)
|
||||
})
|
||||
|
||||
test('an unattributed kill renders no damager sentence rather than an empty one', () => {
|
||||
const hit = one(bossKilled({ damagers: [] }))
|
||||
assert.equal(hit.data.damagerCount, undefined)
|
||||
assert.equal(hit.data.damagerNote, undefined)
|
||||
})
|
||||
|
||||
const city = (over) => ({ kind: 'city.update', city: 'Britain', electionPhase: 'none', ...over })
|
||||
|
||||
test('a governor change is a transition, and never on first sight', () => {
|
||||
|
||||
126
server/test/shardIngest.eventReconcile.test.js
Normal file
126
server/test/shardIngest.eventReconcile.test.js
Normal file
@@ -0,0 +1,126 @@
|
||||
// A shard restart makes the event resource ledger a claim about a world that no
|
||||
// longer exists (EVENTS.md §F, EVENTS_PLAN.md Phases 8 and 9).
|
||||
//
|
||||
// Core cannot notice that on its own — it has no concept of the game being up —
|
||||
// so the module says when, and `server.hello` carrying a *changed* `bootId` is
|
||||
// the only signal that distinguishes a shard restart from a sidecar reconnect.
|
||||
// Getting that wrong in either direction is a real failure: never asking leaves
|
||||
// core believing a ledger of things that are gone, and asking on every reconnect
|
||||
// makes core orphan rows that are perfectly alive.
|
||||
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../utils/shardIngest')
|
||||
|
||||
function makeDeps() {
|
||||
const order = []
|
||||
const noop = async () => {}
|
||||
return {
|
||||
order,
|
||||
shardEvents: { append: noop },
|
||||
shardState: { clearOnline: async () => { order.push('clearOnline') }, upsertOnline: noop, setOffline: noop },
|
||||
shardLinks: {},
|
||||
shardMarket: {},
|
||||
uoLinkConfig: { recordStatus: async (row) => { order.push(`recordStatus:${row.bootId}`) } },
|
||||
settings: { getInstanceName: async () => 'Rig' },
|
||||
broadcast: () => {},
|
||||
pushDispatch: () => {},
|
||||
engagement: () => {},
|
||||
eventsReconcile: () => { order.push('reconcile') },
|
||||
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||
}
|
||||
}
|
||||
|
||||
const hello = (bootId) => ({ kind: 'server.hello', t: '2026-09-04T10:00:00Z', shard: 'Rig', bootId })
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('the first hello of a process is not a restart', async () => {
|
||||
// The website has just come up and the shard has not moved. Everything in the
|
||||
// ledger is still in force, and asking would be core spending a round trip per
|
||||
// module to be told so.
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
assert.ok(!deps.order.includes('reconcile'))
|
||||
})
|
||||
|
||||
test('a sidecar reconnect is not a restart either', async () => {
|
||||
// `server.hello` is sent on EVERY reconnect, and the sidecar dropping its
|
||||
// socket changes nothing in the game. Reconciling here would orphan every live
|
||||
// row — the ledger would still be right and core would stop believing it.
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
assert.ok(!deps.order.includes('reconcile'))
|
||||
})
|
||||
|
||||
test('a changed bootId asks every module to reconcile its ledger', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await shardIngest.ingest(hello('boot-2'), deps)
|
||||
assert.equal(deps.order.filter((s) => s === 'reconcile').length, 1)
|
||||
})
|
||||
|
||||
test('the reconcile happens AFTER the new bootId is recorded', async () => {
|
||||
// The ordering is load-bearing rather than tidy. Every action decides what is
|
||||
// still in force by comparing its stamp against the CURRENT boot id, which it
|
||||
// reads back out of the row `recordStatus` writes. Asking first would compare
|
||||
// every resource against the boot that has just ended — and every one of them
|
||||
// would look live, which is the exact opposite of what a restart means.
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await shardIngest.ingest(hello('boot-2'), deps)
|
||||
|
||||
const recordedAt = deps.order.lastIndexOf('recordStatus:boot-2')
|
||||
const askedAt = deps.order.indexOf('reconcile')
|
||||
assert.ok(recordedAt >= 0 && askedAt >= 0)
|
||||
assert.ok(askedAt > recordedAt, 'reconcile must not run before the new boot id is stored')
|
||||
})
|
||||
|
||||
test('a hello with no bootId at all changes nothing', async () => {
|
||||
// An older plugin, or a frame that lost the field. Not knowing which boot this
|
||||
// is cannot be allowed to read as "a new one".
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await shardIngest.ingest({ kind: 'server.hello', t: '2026-09-04T10:00:00Z', shard: 'Rig' }, deps)
|
||||
assert.ok(!deps.order.includes('reconcile'))
|
||||
})
|
||||
|
||||
test('a backfill replay never reconciles, however many boots it walks through', async () => {
|
||||
// **The defect the live rig found, and nothing else could.** A WS reconnect
|
||||
// replays the last several `server.hello` frames in order — this rig saw three,
|
||||
// each with a different `bootId` — so every replayed frame looks like a
|
||||
// restart. Acting on the intermediate ones would compare a resource stamped
|
||||
// with the CURRENT boot against a boot that ended hours ago and mark it
|
||||
// `orphaned`: a live crier line core will never take down again, lost to
|
||||
// nothing worse than the website reconnecting.
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
for (const boot of ['boot-2', 'boot-3', 'boot-4']) {
|
||||
await shardIngest.ingest(hello(boot), { ...deps, fromBackfill: true })
|
||||
}
|
||||
assert.ok(!deps.order.includes('reconcile'))
|
||||
// The replay still moves the tracked boot on, so the NEXT live hello is
|
||||
// measured against where the replay left off rather than against boot-1.
|
||||
assert.ok(deps.order.includes('recordStatus:boot-4'))
|
||||
})
|
||||
|
||||
test('a live hello after a replay is still a restart', async () => {
|
||||
// The gate is about the frame, not about the module going quiet: skipping the
|
||||
// replay must not make the next genuine restart invisible.
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await shardIngest.ingest(hello('boot-2'), { ...deps, fromBackfill: true })
|
||||
await shardIngest.ingest(hello('boot-3'), deps)
|
||||
assert.equal(deps.order.filter((s) => s === 'reconcile').length, 1)
|
||||
})
|
||||
|
||||
test('a reconcile that throws does not take the ingest down with it', async () => {
|
||||
// Fire-and-forget by the contract, and the feed must survive one bad module:
|
||||
// `ingest()` never throws, because a single event may not kill the socket.
|
||||
const deps = makeDeps()
|
||||
deps.eventsReconcile = () => { throw new Error('registry exploded') }
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await assert.doesNotReject(() => shardIngest.ingest(hello('boot-2'), deps))
|
||||
})
|
||||
@@ -95,6 +95,58 @@ test('an unknown viewer level cannot see a gated kind or a locked field', async
|
||||
assert.equal('webId' in out.leader, false)
|
||||
})
|
||||
|
||||
// ── Protocol 6: the champion defeat ──────────────────────────────────
|
||||
|
||||
const KILL = {
|
||||
kind: 'champ.boss.killed',
|
||||
serial: '0x40012345',
|
||||
boss: 'Semidar',
|
||||
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
|
||||
damagers: [
|
||||
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', webId: '7', player: true, damage: 900 },
|
||||
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
|
||||
],
|
||||
}
|
||||
|
||||
test('the kill is public and its damage table is not', () => {
|
||||
const config = visibility.compileDefaults()
|
||||
// The whole shape of this addition in one assertion: a champion falling is
|
||||
// content the public board is FOR, and a ranked roll of who was strong enough
|
||||
// to fell it is a performance record nobody published on purpose.
|
||||
assert.equal(visibility.kindVisibleTo('champ.boss.killed', 'anonymous', config), true)
|
||||
for (const level of ['anonymous', 'logged_in', 'player']) {
|
||||
const out = visibility.projectFeature('champs', KILL, level, config)
|
||||
assert.equal(out.boss, 'Semidar', `${level} sees which boss fell`)
|
||||
assert.equal('damagers' in out, false, `${level} must not see the damage table`)
|
||||
}
|
||||
assert.equal(visibility.projectFeature('champs', KILL, 'staff', config).damagers.length, 2)
|
||||
})
|
||||
|
||||
test('the killer rides the frame the way mob.killed already publishes one', () => {
|
||||
// Deliberately NOT a configurable field. It is one actor, announced in-game to
|
||||
// everyone present, and the same disclosure the public activity feed has made
|
||||
// through `mob.killed` since before this framework existed.
|
||||
const config = visibility.compileDefaults()
|
||||
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
|
||||
assert.equal(out.killer.name, 'Aldric')
|
||||
assert.equal('acct' in out.killer, false, 'rule 1 still applies inside it')
|
||||
})
|
||||
|
||||
test('an admin who lowers the damager rule still cannot see an account inside it', () => {
|
||||
// Rule 1 beats a field rule wherever the two meet, and a damager entry is an
|
||||
// actor object like any other. An admin who opens the table to everyone has
|
||||
// published character names, which is what they chose; they have not published
|
||||
// account names, which is not theirs to choose.
|
||||
const config = visibility.compileDefaults()
|
||||
config.champs.fields = { ...config.champs.fields, damagers: 'anonymous' }
|
||||
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
|
||||
assert.equal(out.damagers.length, 2)
|
||||
assert.equal(out.damagers[0].name, 'Aldric')
|
||||
assert.equal(out.damagers[0].damage, 900)
|
||||
assert.equal('acct' in out.damagers[0], false)
|
||||
assert.equal('webId' in out.damagers[0], false)
|
||||
})
|
||||
|
||||
// ── Rule 1: locked fields ──────────────────────────────────────────────────
|
||||
|
||||
test('acct and webId are stripped below admin regardless of feature config', () => {
|
||||
@@ -360,10 +412,21 @@ const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
|
||||
// inside the roster's member array (see the roster test above).
|
||||
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
|
||||
|
||||
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => {
|
||||
// v6 adds the champion defeat. It rides the existing `champs` feature, which is
|
||||
// already anonymous, so the KIND is public — while the `damagers` table on it is
|
||||
// `staff` by field rule. That split is the point: a shard announces that its
|
||||
// champion fell without publishing a roll of who was strong enough to fell it.
|
||||
const V6_ADDED_PUBLIC_KINDS = ['champ.boss.killed']
|
||||
|
||||
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3, v4 and v6 additions', () => {
|
||||
assert.deepEqual(
|
||||
[...visibility.PUBLIC_KINDS].sort(),
|
||||
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_ADDED_PUBLIC_KINDS].sort(),
|
||||
[
|
||||
...PRE_V3_PUBLIC_KINDS,
|
||||
...V3_ADDED_PUBLIC_KINDS,
|
||||
...V4_ADDED_PUBLIC_KINDS,
|
||||
...V6_ADDED_PUBLIC_KINDS,
|
||||
].sort(),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
474
server/test/uoEventActions.test.js
Normal file
474
server/test/uoEventActions.test.js
Normal file
@@ -0,0 +1,474 @@
|
||||
// module-uo's event verbs, wave 1 (EVENTS_PLAN.md Phase 9).
|
||||
//
|
||||
// The declarations are data plus three `perform()`s, so most of this suite is
|
||||
// about the *shapes* core will check and the failure paths a live rig cannot be
|
||||
// made to produce on demand — a sidecar that answers 409, a shard that restarts
|
||||
// between two steps, a crier line one character over the cap.
|
||||
//
|
||||
// **The first test is the one the whole phase rests on.** Every other property
|
||||
// here — "a broadcast is sent once", "a failed post is retried" — is a claim
|
||||
// about what the MODULE decided, and the module only gets to decide when its
|
||||
// client answers before core's dispatch deadline. Assert the relationship, not
|
||||
// the numbers, or the day someone tunes one of them the suite stays green while
|
||||
// the behaviour inverts.
|
||||
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
require('./_setup')
|
||||
const actions = require('../config/uoEventActions')
|
||||
|
||||
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
||||
|
||||
let calls
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = { broadcast: [], crier: [], crierDel: [], news: [], newsDel: [] }
|
||||
for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) {
|
||||
saved[name] = uoLinkClient[name]
|
||||
}
|
||||
saved.getSafe = uoLinkConfig.getSafe
|
||||
saved.listRegions = shardAtlas.listRegions
|
||||
saved.listLandmarks = shardAtlas.listLandmarks
|
||||
saved.searchCreatures = shardAtlas.searchCreatures
|
||||
|
||||
uoLinkClient.adminBroadcast = async (b) => { calls.broadcast.push(b); return { ok: true, status: 200 } }
|
||||
uoLinkClient.postTownCrier = async (b) => { calls.crier.push(b); return { ok: true, status: 200 } }
|
||||
uoLinkClient.deleteTownCrier = async (id) => { calls.crierDel.push(id); return { ok: true, status: 200 } }
|
||||
uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } }
|
||||
uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } }
|
||||
uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) {
|
||||
uoLinkClient[name] = saved[name]
|
||||
}
|
||||
uoLinkConfig.getSafe = saved.getSafe
|
||||
shardAtlas.listRegions = saved.listRegions
|
||||
shardAtlas.listLandmarks = saved.listLandmarks
|
||||
shardAtlas.searchCreatures = saved.searchCreatures
|
||||
})
|
||||
|
||||
// ── The rule everything else depends on ────────────────────────────────────
|
||||
|
||||
test('every action outlives the sidecar client, so the module classifies its own failures', () => {
|
||||
// `dispatch.classify()` answers `retry` for a budget timeout unconditionally
|
||||
// and never asks the action. If core's deadline can fire before the client
|
||||
// gives up, `retry: false` below is unreachable and a broadcast is retried.
|
||||
for (const action of actions.ACTIONS) {
|
||||
assert.ok(
|
||||
action.budgetMs > uoLinkClient.TIMEOUT_MS,
|
||||
`${action.id} budgetMs (${action.budgetMs}) must exceed uoLinkClient.TIMEOUT_MS (${uoLinkClient.TIMEOUT_MS})`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The declarations, against the checks core will run ─────────────────────
|
||||
|
||||
test('the declarations satisfy the shape core validates them with', () => {
|
||||
const RISKS = ['notify', 'inspect', 'change', 'irreversible']
|
||||
const REVERSIBLE = ['none', 'self', 'ledger', 'override']
|
||||
const PARAM_TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url']
|
||||
|
||||
for (const a of actions.ACTIONS) {
|
||||
assert.ok(a.id.startsWith('uo.'), `${a.id} must be namespaced to this module`)
|
||||
assert.ok(a.label && a.description, `${a.id} needs a label and a description`)
|
||||
assert.ok(RISKS.includes(a.risk), `${a.id} has an unknown risk class`)
|
||||
assert.ok(REVERSIBLE.includes(a.reversible), `${a.id} has an unknown reversible class`)
|
||||
assert.equal(typeof a.perform, 'function')
|
||||
|
||||
// `revert` is required iff ledger, and forbidden otherwise — a revert on a
|
||||
// non-ledgering action is an undo core will never call.
|
||||
assert.equal(
|
||||
typeof a.revert === 'function',
|
||||
a.reversible === 'ledger',
|
||||
`${a.id} revert() must be present exactly when reversible is 'ledger'`,
|
||||
)
|
||||
// `reconcile` is optional, but only meaningful where something is ledgered.
|
||||
if (a.reconcile !== undefined) {
|
||||
assert.equal(typeof a.reconcile, 'function')
|
||||
assert.ok(a.reversible === 'ledger' || a.reversible === 'override', `${a.id} reconciles but ledgers nothing`)
|
||||
}
|
||||
if (a.cost !== undefined) assert.equal(typeof a.cost, 'function')
|
||||
|
||||
const names = new Set()
|
||||
for (const p of a.params) {
|
||||
assert.ok(!names.has(p.name), `${a.id} declares ${p.name} twice`)
|
||||
names.add(p.name)
|
||||
assert.ok(PARAM_TYPES.includes(p.type), `${a.id}.${p.name} has an unsupported type "${p.type}"`)
|
||||
// Required on every param including the optional ones: it is the authoring
|
||||
// placeholder, and an unattended world write typed into a blank box is how
|
||||
// a typo gets scheduled.
|
||||
assert.ok(
|
||||
p.example !== undefined && p.example !== null && p.example !== '',
|
||||
`${a.id}.${p.name} needs an example`,
|
||||
)
|
||||
assert.ok(p.description, `${a.id}.${p.name} needs a description`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a broadcast spends the one budget dimension the module declares', () => {
|
||||
const declared = new Set(actions.BUDGETS.map((b) => b.id))
|
||||
assert.deepEqual([...declared], ['uo.broadcasts'])
|
||||
for (const b of actions.BUDGETS) {
|
||||
assert.ok(b.id.startsWith('uo.'), 'a budget dimension must be namespaced')
|
||||
assert.ok(b.label && b.unit, 'a dimension is rendered as a label and a unit beside a number')
|
||||
}
|
||||
|
||||
// Every dimension a cost names must be one the module declared, or core is
|
||||
// asked to bound something nothing defines.
|
||||
const cost = byId('uo.broadcast').cost({})
|
||||
assert.deepEqual(cost, { 'uo.broadcasts': 1 })
|
||||
for (const id of Object.keys(cost)) assert.ok(declared.has(id), `${id} is spent but never declared`)
|
||||
|
||||
// The keyed verbs deliberately spend nothing: a repeat REPLACES under the same
|
||||
// id, so there is no runaway for a cap to bound.
|
||||
assert.equal(byId('uo.towncrier.post').cost, undefined)
|
||||
assert.equal(byId('uo.news.post').cost, undefined)
|
||||
})
|
||||
|
||||
// ── uo.broadcast: retried, because protocol 6 made that safe ───────────────
|
||||
|
||||
test('a broadcast is retried on a transient failure and never on a permanent one', async () => {
|
||||
const broadcast = byId('uo.broadcast')
|
||||
// Wave 1 asserted the opposite of this — every failure terminal, including the
|
||||
// two that are plainly transient — because nothing on the wire could stop a
|
||||
// retry announcing to everyone twice. Protocol 6 puts an idempotency key on the
|
||||
// command and the shard refuses the repeat, so the trade that test recorded is
|
||||
// no longer one that has to be made.
|
||||
//
|
||||
// 425 is the new status in this list: `bridge.busy`, the shard saying a command
|
||||
// under this key is still in flight. Transient by construction.
|
||||
const TRANSIENT = new Set([0, 425, 503, 504])
|
||||
for (const status of [0, 400, 401, 403, 409, 425, 503, 504]) {
|
||||
uoLinkClient.adminBroadcast = async () => ({ ok: false, status, error: `status ${status}` })
|
||||
const result = await broadcast.perform({ runId: 7, params: { text: 'hear ye' }, verify: false })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.retry, TRANSIENT.has(status), `a ${status} retries iff it is transient`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every write carries the step idempotency key, unchanged', async () => {
|
||||
// The key is what makes the retry above safe, so a verb that dropped it would
|
||||
// silently restore the wave-1 hazard while every other assertion still passed.
|
||||
// Asserted per verb rather than once, because each builds its own body.
|
||||
const KEY = 'a'.repeat(40)
|
||||
const seen = {}
|
||||
|
||||
uoLinkClient.adminBroadcast = async (body) => { seen.broadcast = body; return { ok: true } }
|
||||
uoLinkClient.postTownCrier = async (body) => { seen.crier = body; return { ok: true } }
|
||||
uoLinkClient.postNews = async (body) => { seen.news = body; return { ok: true } }
|
||||
|
||||
await byId('uo.broadcast').perform({
|
||||
runId: 7, idempotencyKey: KEY, params: { text: 'hear ye' }, verify: false,
|
||||
})
|
||||
await byId('uo.towncrier.post').perform({
|
||||
runId: 7, idempotencyKey: KEY, params: { lines: 'hear ye' }, verify: false,
|
||||
})
|
||||
await byId('uo.news.post').perform({
|
||||
runId: 7, idempotencyKey: KEY, params: { title: 'A thing', body: 'happened' }, verify: false,
|
||||
})
|
||||
|
||||
assert.equal(seen.broadcast.idempotencyKey, KEY)
|
||||
assert.equal(seen.crier.idempotencyKey, KEY)
|
||||
assert.equal(seen.news.idempotencyKey, KEY)
|
||||
// The two keyed verbs post under an id DERIVED from the key. Both travel: the
|
||||
// id is what makes a repeat replace, the key is what stops it re-announcing.
|
||||
assert.equal(seen.crier.id, `evt-${KEY}`)
|
||||
assert.equal(seen.news.id, `evt-${KEY}`)
|
||||
})
|
||||
|
||||
test("the shard's own words reach the run log, not just a status code", async () => {
|
||||
// **The rig found this.** The sidecar refuses a broadcast with
|
||||
// `{"reason":"admin write plane disabled"}` and `legError` looks for
|
||||
// `data.message`, so the run console read "sidecar responded 403" for a cause
|
||||
// the shard had already explained in a sentence. A staff member clicking a
|
||||
// button knows what they switched off; an event that ran at four in the morning
|
||||
// leaves the run log as the only place anyone will learn why.
|
||||
uoLinkClient.adminBroadcast = async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
data: { kind: 'admin.error', reason: 'admin write plane disabled' },
|
||||
error: 'sidecar responded 403',
|
||||
})
|
||||
const result = await byId('uo.broadcast').perform({ runId: 1, params: { text: 'hear ye' }, verify: false })
|
||||
assert.match(result.error, /admin write plane disabled/)
|
||||
// And NOT the double-announce clause: a 403 will not succeed on any attempt, so
|
||||
// pointing an operator at a policy decision misdirects them away from the
|
||||
// switch they actually have to flip.
|
||||
assert.doesNotMatch(result.error, /announce twice/)
|
||||
assert.equal(result.retry, false)
|
||||
})
|
||||
|
||||
test('a permanent refusal of a keyed verb is not retried either', async () => {
|
||||
// Same distinction on the other side: the keyed verbs DO retry a transient, and
|
||||
// must not burn three attempts on a refusal that cannot change.
|
||||
uoLinkClient.postTownCrier = async () => ({ ok: false, status: 403, data: { reason: 'admin write plane disabled' } })
|
||||
const result = await byId('uo.towncrier.post').perform({
|
||||
runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines: 'hear ye' }, verify: false,
|
||||
})
|
||||
assert.equal(result.retry, false)
|
||||
assert.match(result.error, /admin write plane disabled/)
|
||||
})
|
||||
|
||||
test('a broadcast names its run in the shard audit, not a staff member', async () => {
|
||||
await byId('uo.broadcast').perform({ runId: 42, params: { text: 'hear ye', hue: 1153 }, verify: false })
|
||||
assert.equal(calls.broadcast.length, 1)
|
||||
assert.equal(calls.broadcast[0].actor, 'event:42')
|
||||
assert.equal(calls.broadcast[0].hue, 1153)
|
||||
})
|
||||
|
||||
test('an over-long broadcast is refused by the DRY RUN, before anything is sent', async () => {
|
||||
const broadcast = byId('uo.broadcast')
|
||||
const text = 'x'.repeat(actions.MAX_BROADCAST_LEN + 1)
|
||||
|
||||
const dry = await broadcast.perform({ runId: 1, params: { text }, verify: true })
|
||||
assert.equal(dry.ok, false)
|
||||
assert.equal(dry.retry, false)
|
||||
assert.match(dry.error, new RegExp(String(actions.MAX_BROADCAST_LEN)))
|
||||
|
||||
const live = await broadcast.perform({ runId: 1, params: { text }, verify: false })
|
||||
assert.equal(live.ok, false)
|
||||
assert.deepEqual(calls.broadcast, [], 'nothing may reach the shard once the cap is breached')
|
||||
})
|
||||
|
||||
test('a dry run sends nothing at all', async () => {
|
||||
for (const action of actions.ACTIONS) {
|
||||
const params = {}
|
||||
for (const p of action.params) if (p.required) params[p.name] = p.example
|
||||
const result = await action.perform({ runId: 1, stepId: 1, idempotencyKey: 'k'.repeat(40), params, verify: true })
|
||||
assert.equal(result.ok, true, `${action.id} refused its own example params`)
|
||||
assert.equal(result.resources, undefined, `${action.id} reported a resource it never created`)
|
||||
}
|
||||
assert.deepEqual(
|
||||
[calls.broadcast.length, calls.crier.length, calls.news.length],
|
||||
[0, 0, 0],
|
||||
'a dry run reached the shard',
|
||||
)
|
||||
})
|
||||
|
||||
// ── The keyed verbs: one id, stable across a retry ─────────────────────────
|
||||
|
||||
test('the crier and the news gump post under a run-stable id a retry replaces', async () => {
|
||||
const key = 'a1b2c3'.padEnd(40, '0')
|
||||
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
|
||||
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
|
||||
|
||||
assert.equal(calls.crier.length, 2)
|
||||
assert.equal(calls.crier[0].id, calls.crier[1].id, 'a retry must replace, not stack')
|
||||
assert.equal(calls.crier[0].id, `evt-${key}`)
|
||||
// The sidecar's own cap on the id column.
|
||||
assert.ok(calls.crier[0].id.length <= 64)
|
||||
})
|
||||
|
||||
test('an event article cannot collide with a website post in the news gump', async () => {
|
||||
// `newsGump.js` posts site articles under the bare post id and re-pushes that
|
||||
// whole set on every reconnect. An event article numbered into the same space
|
||||
// would silently be a collision with a post, in whichever direction wrote last.
|
||||
await byId('uo.news.post').perform({
|
||||
runId: 9,
|
||||
idempotencyKey: 'f'.repeat(40),
|
||||
params: { title: 'The Fair', body: 'Merchants gather.' },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(calls.news.length, 1)
|
||||
assert.doesNotMatch(calls.news[0].id, /^\d+$/, 'an event article must not be numbered like a post')
|
||||
assert.match(calls.news[0].id, /^evt-/)
|
||||
assert.match(calls.news[0].body, /<CENTER>The Fair<\/CENTER>/)
|
||||
assert.equal(calls.news[0].announce, true, 'announce defaults on, as the gump does')
|
||||
})
|
||||
|
||||
test('the keyed verbs DO retry, because a repeat replaces', async () => {
|
||||
for (const [id, stub] of [['uo.towncrier.post', 'postTownCrier'], ['uo.news.post', 'postNews']]) {
|
||||
const params = { lines: 'hear ye', title: 'The Fair', body: 'Merchants gather.' }
|
||||
// The announce leg's own classification of this transport, reused rather
|
||||
// than re-decided: a config or data problem is terminal, the rest transient.
|
||||
for (const [status, retry] of [[400, false], [401, false], [403, false], [409, false], [503, true], [504, true], [0, true]]) {
|
||||
uoLinkClient[stub] = async () => ({ ok: false, status, error: `status ${status}` })
|
||||
const result = await byId(id).perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params, verify: false })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.retry, retry, `${id} misclassified a ${status}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a crier post is refused before it is sent when it is not eight short lines', async () => {
|
||||
const crier = byId('uo.towncrier.post')
|
||||
const cases = [
|
||||
['', /empty/],
|
||||
[' \n ', /empty/],
|
||||
[Array.from({ length: actions.MAX_CRIER_LINES + 1 }, (_, i) => `line ${i}`).join('\n'), /criers carry/],
|
||||
['x'.repeat(actions.MAX_CRIER_LINE_LEN + 1), /capped at/],
|
||||
]
|
||||
for (const [lines, expected] of cases) {
|
||||
const result = await crier.perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines }, verify: false })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.retry, false, 'a badly shaped message is just as badly shaped next minute')
|
||||
assert.match(result.error, expected)
|
||||
}
|
||||
assert.deepEqual(calls.crier, [])
|
||||
})
|
||||
|
||||
test('blank lines are dropped rather than counted against the cap', () => {
|
||||
// A textarea an operator has pressed enter in twice still holds two lines.
|
||||
const parsed = actions.crierLines('hear ye\n\n \nseek the herald\n')
|
||||
assert.equal(parsed.ok, true)
|
||||
assert.deepEqual(parsed.lines, ['hear ye', 'seek the herald'])
|
||||
})
|
||||
|
||||
test('a crier duration is taken in minutes and bounded at the sidecar cap', async () => {
|
||||
const crier = byId('uo.towncrier.post')
|
||||
const base = { runId: 1, idempotencyKey: 'k'.repeat(40), verify: false }
|
||||
|
||||
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 90 } })
|
||||
assert.equal(calls.crier[0].durationSec, 5400)
|
||||
|
||||
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 60 * 48 } })
|
||||
assert.equal(calls.crier[1].durationSec, 86400, 'a duration past the sidecar cap is clamped, not refused')
|
||||
|
||||
// Left out entirely, so the sidecar applies its own default rather than the
|
||||
// module inventing one.
|
||||
await crier.perform({ ...base, params: { lines: 'hear ye' } })
|
||||
assert.equal(calls.crier[2].durationSec, undefined)
|
||||
|
||||
const bad = await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 'soon' } })
|
||||
assert.equal(bad.ok, false)
|
||||
assert.equal(bad.retry, false)
|
||||
})
|
||||
|
||||
// ── Giving it back ─────────────────────────────────────────────────────────
|
||||
|
||||
test('a resource that is already gone is a successful revert', async () => {
|
||||
// §L: "gone, and that is fine". A crier line whose duration ran out is a 404,
|
||||
// and it is the outcome teardown wanted.
|
||||
uoLinkClient.deleteTownCrier = async () => ({ ok: false, status: 404 })
|
||||
uoLinkClient.deleteNews = async () => ({ ok: false, status: 404 })
|
||||
|
||||
for (const id of ['uo.towncrier.post', 'uo.news.post']) {
|
||||
const result = await byId(id).revert({ runId: 1, resources: [{ kind: 'x', ref: 'evt-1' }] })
|
||||
assert.equal(result.ok, true)
|
||||
assert.ok(!result.failed || !result.failed.length)
|
||||
}
|
||||
})
|
||||
|
||||
test('a revert names the resources that did not come back', async () => {
|
||||
uoLinkClient.deleteTownCrier = async (id) => {
|
||||
calls.crierDel.push(id)
|
||||
return id === 'evt-bad' ? { ok: false, status: 503 } : { ok: true, status: 200 }
|
||||
}
|
||||
const result = await byId('uo.towncrier.post').revert({
|
||||
runId: 1,
|
||||
resources: [{ ref: 'evt-ok' }, { ref: 'evt-bad' }],
|
||||
})
|
||||
// `ok: true` with a `failed` list, not `ok: false`: the group was worked, and
|
||||
// one member of it is outstanding. Core keeps the row and tries it again.
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual(result.failed, ['evt-bad'])
|
||||
assert.deepEqual(calls.crierDel, ['evt-ok', 'evt-bad'], 'one failure must not stop the group')
|
||||
})
|
||||
|
||||
// ── reconcile: the boot stamp ──────────────────────────────────────────────
|
||||
|
||||
test('a resource stamped with the current boot is still in force', async () => {
|
||||
const resources = [
|
||||
{ kind: 'towncrier', ref: 'evt-a', payload: { bootId: 'boot-1' } },
|
||||
{ kind: 'towncrier', ref: 'evt-b', payload: { bootId: 'boot-0' } },
|
||||
]
|
||||
const result = await actions.reconcileByBootId({ resources })
|
||||
assert.equal(result.ok, true)
|
||||
// Only the row from the boot that is still running. Core orphans the other —
|
||||
// which is the honest sentence: it vanished while nobody was looking, rather
|
||||
// than core having put it back.
|
||||
assert.deepEqual(result.inForce, ['evt-a'])
|
||||
})
|
||||
|
||||
test('a resource with no stamp is reported in force, because "I do not know" is not "it is gone"', async () => {
|
||||
const result = await actions.reconcileByBootId({
|
||||
resources: [{ ref: 'evt-old', payload: null }, { ref: 'evt-older', payload: {} }],
|
||||
})
|
||||
assert.deepEqual(result.inForce, ['evt-old', 'evt-older'])
|
||||
})
|
||||
|
||||
test('with no shard boot to compare against, reconcile declines rather than orphaning everything', async () => {
|
||||
uoLinkConfig.getSafe = async () => ({ bootId: null })
|
||||
const result = await actions.reconcileByBootId({ resources: [{ ref: 'evt-a', payload: { bootId: 'boot-1' } }] })
|
||||
// Core treats anything that is not an explicit answer as unanswered and leaves
|
||||
// the ledger alone. An `ok: true, inForce: []` here would abandon every live row
|
||||
// on a website that came up before its sidecar did.
|
||||
assert.equal(result.ok, false)
|
||||
})
|
||||
|
||||
test('a write with an unreadable config still happens, and simply carries no stamp', async () => {
|
||||
uoLinkConfig.getSafe = async () => { throw new Error('pool is down') }
|
||||
const result = await byId('uo.towncrier.post').perform({
|
||||
runId: 1,
|
||||
idempotencyKey: 'k'.repeat(40),
|
||||
params: { lines: 'hear ye' },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(result.ok, true, 'a config read must not fail a world write')
|
||||
assert.equal(result.resources[0].payload.bootId, null)
|
||||
})
|
||||
|
||||
// ── Option sources ─────────────────────────────────────────────────────────
|
||||
|
||||
const source = (id) => actions.OPTION_SOURCES.find((s) => s.id === id)
|
||||
|
||||
test('every option source is namespaced and answers', () => {
|
||||
for (const s of actions.OPTION_SOURCES) {
|
||||
assert.ok(s.id.startsWith('uo.options.'), `${s.id} must be namespaced`)
|
||||
assert.ok(s.label && s.description)
|
||||
assert.equal(typeof s.resolve, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
test('a place is named by its facet, because two facets both have a Britain', async () => {
|
||||
shardAtlas.listRegions = async () => [
|
||||
{ facet: 'Felucca', name: 'Britain' },
|
||||
{ facet: 'Trammel', name: 'Britain' },
|
||||
]
|
||||
const options = await source('uo.options.regions').resolve()
|
||||
assert.equal(new Set(options.map((o) => o.value)).size, 2, 'two different places must not share a value')
|
||||
assert.deepEqual(options[0], { value: 'Felucca/Britain', label: 'Britain', group: 'Felucca' })
|
||||
})
|
||||
|
||||
test('a landmark groups by the atlas grouping where it has one, the facet otherwise', async () => {
|
||||
shardAtlas.listLandmarks = async () => [
|
||||
{ facet: 'Felucca', name: 'Despise', group: 'Dungeons' },
|
||||
{ facet: 'Felucca', name: 'Cove', group: null },
|
||||
]
|
||||
const options = await source('uo.options.landmarks').resolve()
|
||||
assert.deepEqual(options.map((o) => o.group), ['Dungeons', 'Felucca'])
|
||||
})
|
||||
|
||||
test('a creature needs no qualifier — the slug is the same type wherever it spawns', async () => {
|
||||
shardAtlas.searchCreatures = async ({ limit }) => {
|
||||
assert.equal(limit, actions.MAX_OPTIONS, 'the source must bound what it asks the atlas for')
|
||||
return { creatures: [{ slug: 'orc-brute', name: 'Orc Brute' }] }
|
||||
}
|
||||
assert.deepEqual(await source('uo.options.creatures').resolve(), [
|
||||
{ value: 'orc-brute', label: 'Orc Brute' },
|
||||
])
|
||||
})
|
||||
|
||||
test('an atlas larger than the dropdown bound is truncated and said so', async () => {
|
||||
const { ctx } = require('./_setup')
|
||||
shardAtlas.listRegions = async () =>
|
||||
Array.from({ length: actions.MAX_OPTIONS + 5 }, (_, i) => ({ facet: 'Felucca', name: `Region ${i}` }))
|
||||
const options = await source('uo.options.regions').resolve()
|
||||
assert.equal(options.length, actions.MAX_OPTIONS)
|
||||
// Silently serving 2000 of 2005 is the defect the bound would otherwise
|
||||
// introduce: an author cannot find the landmark they are looking for and
|
||||
// nothing anywhere says why.
|
||||
const warned = ctx.logs
|
||||
.filter((l) => l.namespace === 'uo-events')
|
||||
.flatMap((l) => l.log.warn.calls)
|
||||
.some(([message]) => /truncated/.test(message))
|
||||
assert.ok(warned, 'a truncated source must leave a log line naming itself')
|
||||
})
|
||||
@@ -662,6 +662,53 @@ const MAPPERS = {
|
||||
}
|
||||
},
|
||||
|
||||
// Protocol 6. A boss defeat, which until now could only be GUESSED at from
|
||||
// `champ.update` losing its `bossUp` — a signal that also fires when a spawn is
|
||||
// reset by a GM, when a boss despawns, and when the sweep simply reconnects.
|
||||
// This one fires on the death itself.
|
||||
//
|
||||
// **The subject is the SPAWN, so it matches `uo.champ.boss_up`'s.** A rule with
|
||||
// a cooldown on one altar therefore counts a boss going up and that same boss
|
||||
// coming down as the same subject, which is what an operator writing "not more
|
||||
// than once an hour about Destard" means. A kill the shard could not attribute
|
||||
// to an altar carries no spawn, so the boss's own serial stands in — it is a
|
||||
// subject that exists exactly once, which is all a cooldown needs of it.
|
||||
//
|
||||
// **Damagers are not surfaced as variables.** The table is on the frame and it
|
||||
// is `staff` in the visibility config; putting names into a trigger's data
|
||||
// would route them into mail an operator can address to `subscribers`, which is
|
||||
// the field rule undone one layer up. `damagerCount` is a number and says the
|
||||
// thing worth saying: how many took part.
|
||||
'champ.boss.killed': (ev, tracker, out) => {
|
||||
const spawnSerial = ev.serial == null ? null : String(ev.serial)
|
||||
const bossSerial = ev.bossSerial == null ? null : String(ev.bossSerial)
|
||||
const subject = spawnSerial || bossSerial
|
||||
if (!subject) return
|
||||
|
||||
// The board no longer has a boss on this altar. Kept in step with the sweep's
|
||||
// own view so the next `champ.update` carrying `bossUp: true` is read as a
|
||||
// transition rather than as more of the same.
|
||||
if (spawnSerial) tracker.champBossUp.set(spawnSerial, false)
|
||||
|
||||
const damagers = Array.isArray(ev.damagers) ? ev.damagers : []
|
||||
|
||||
out.push({
|
||||
triggerId: 'uo.champ.boss_killed',
|
||||
data: defined({
|
||||
spawnSerial: subject,
|
||||
champsUrl: PATHS.champs,
|
||||
bossName: ev.boss || ev.bossType || 'the champion',
|
||||
category: ev.category || undefined,
|
||||
location: place(ev),
|
||||
atPlace: trailing(place(ev), (p) => ` at ${p}`),
|
||||
killerName: actorName(ev.killer),
|
||||
damagerCount: damagers.length || undefined,
|
||||
damagerNote: trailing(damagers.length || null, (n) =>
|
||||
n === 1 ? ' One player fought it.' : ` ${n} players fought it.`),
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
'champ.remove': (ev, tracker) => {
|
||||
if (ev.serial == null) return
|
||||
tracker.champActive.delete(String(ev.serial))
|
||||
|
||||
@@ -17,7 +17,7 @@ const shardStateModel = require('../model/shardState/shardState.model')
|
||||
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||
const shardMarketModel = require('../model/shardMarket/shardMarket.model')
|
||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const { settings: settingsModel } = require('../core')
|
||||
const { settings: settingsModel, events: coreEvents } = require('../core')
|
||||
const broadcaster = require('./shardBroadcast')
|
||||
const shardPush = require('./shardPush')
|
||||
const shardEngagement = require('./shardEngagement')
|
||||
@@ -103,11 +103,12 @@ async function resolveShardName(shard, deps) {
|
||||
|
||||
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
||||
async function applyStateChange(event, deps) {
|
||||
const { shardState, uoLinkConfig, log } = deps
|
||||
const { shardState, uoLinkConfig, eventsReconcile, fromBackfill, log } = deps
|
||||
switch (event.kind) {
|
||||
case 'server.hello': {
|
||||
const incoming = event.bootId || null
|
||||
if (incoming && state.bootId && incoming !== state.bootId) {
|
||||
const restarted = Boolean(incoming && state.bootId && incoming !== state.bootId)
|
||||
if (restarted) {
|
||||
log.warn('shard restarted (bootId changed) — clearing online roster', {
|
||||
from: state.bootId,
|
||||
to: incoming,
|
||||
@@ -116,6 +117,31 @@ async function applyStateChange(event, deps) {
|
||||
}
|
||||
if (incoming) state.bootId = incoming
|
||||
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
|
||||
if (restarted && !fromBackfill) {
|
||||
// EVENTS.md F: core has no concept of the game being up, so the module
|
||||
// says when a ledger of live shard resources has become a claim about a
|
||||
// world that no longer exists. This is that moment, and a changed
|
||||
// `bootId` is the only thing that distinguishes it from a sidecar
|
||||
// reconnect — which changes nothing in the game and must not orphan a row.
|
||||
//
|
||||
// **After `recordStatus`, and that ordering is load-bearing.** Every
|
||||
// action's `reconcile()` decides what is still in force by comparing its
|
||||
// stamp against the CURRENT boot id, which it reads back out of this
|
||||
// row. Asking first would have every resource compared against the boot
|
||||
// that has just ended, and every one of them would look live.
|
||||
//
|
||||
// **And never on a backfill replay**, which is the same rule the
|
||||
// engagement fan-out and the SSE broadcast state below and is far more
|
||||
// expensive to break here. A reconnect replays the last several
|
||||
// `server.hello` frames in order — this rig saw three, each with a
|
||||
// different `bootId` — so every replayed frame looks like a restart, and
|
||||
// the intermediate ones would compare a resource stamped with the CURRENT
|
||||
// boot against a boot that ended hours ago. The row is then `orphaned`:
|
||||
// a live crier line core will never take down again, lost to nothing
|
||||
// worse than the website reconnecting. The website-was-down case is not
|
||||
// missed by skipping these — core asks every module at its own boot.
|
||||
eventsReconcile()
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'server.shutdown':
|
||||
@@ -292,6 +318,15 @@ function resolveDeps(deps) {
|
||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
|
||||
engagement: deps.engagement || shardEngagement.fromShardEvent,
|
||||
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). Injectable for the same reason
|
||||
// every member above is: a test that asserted a shard restart triggers a
|
||||
// reconcile must be able to see the call without a live event engine behind
|
||||
// it.
|
||||
eventsReconcile: deps.eventsReconcile || (() => coreEvents.reconcile()),
|
||||
// Not injectable — it is the caller's statement about this frame rather than
|
||||
// a dependency. It reaches `applyStateChange` because the reconcile below is
|
||||
// the one state change that must not act on a replay; see the note there.
|
||||
fromBackfill: Boolean(deps.fromBackfill),
|
||||
log: deps.log || defaultLog,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,27 @@ const FEATURES = {
|
||||
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||
status: { audience: 'anonymous', fields: {} },
|
||||
activity: { audience: 'anonymous', fields: {} },
|
||||
champs: { audience: 'anonymous', fields: {} },
|
||||
// Protocol 6 adds `champ.boss.killed` to this feature, and with it the first
|
||||
// field on a champs frame that is about PEOPLE rather than about an altar.
|
||||
//
|
||||
// `damagers` is the ranked table of who fought the boss and for how much. It is
|
||||
// the honest basis for "who slew the champion" and it is also a performance
|
||||
// record of named players that nobody consented to publish, which is precisely
|
||||
// the tension the ladder exists to let a shard resolve for itself. It defaults
|
||||
// to `staff`: the kill is public (a champion falling is announced in-world and
|
||||
// is the content the board is for), the roll of who did the damage is not. A
|
||||
// shard that wants a public board lowers one rule.
|
||||
//
|
||||
// Nested for the same reason `market.fees` and `houses.schedule` are: one rule
|
||||
// covers the whole table rather than a rule per column, and the columns here
|
||||
// are actor objects whose `acct`/`webId` remain admin-only by the locked-field
|
||||
// rule regardless of what this is set to.
|
||||
//
|
||||
// `killer` is deliberately NOT listed. It is the single actor whose blow landed
|
||||
// last, it is announced in-game to everyone present, and it is the same shape
|
||||
// and the same disclosure `mob.killed` has published on the public activity
|
||||
// feed since before this framework existed.
|
||||
champs: { audience: 'anonymous', fields: { damagers: 'staff' } },
|
||||
guilds: { audience: 'anonymous', fields: {} },
|
||||
governors: { audience: 'anonymous', fields: {} },
|
||||
// The public Houses page showed IDOC location only; owner/price were staff.
|
||||
@@ -189,6 +209,10 @@ const KIND_FEATURE = new Map(
|
||||
// boards
|
||||
'champ.update': 'champs',
|
||||
'champ.remove': 'champs',
|
||||
// Protocol 6. Without this line rule 2 would fail the new kind closed to
|
||||
// admin-only — correct as a default, and wrong as an outcome: a champion
|
||||
// falling is exactly what the public board is for.
|
||||
'champ.boss.killed': 'champs',
|
||||
'guild.update': 'guilds',
|
||||
'guild.remove': 'guilds',
|
||||
'guild.join': 'guilds',
|
||||
|
||||
@@ -11,11 +11,48 @@
|
||||
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
|
||||
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
|
||||
// token on every call.
|
||||
//
|
||||
// ── Protocol 6: `idempotencyKey` on a write ────────────────────────────────
|
||||
//
|
||||
// The three write helpers the event engine drives take an optional
|
||||
// `idempotencyKey`, which the sidecar passes to the shard verbatim. The shard
|
||||
// executes a key at most once and answers a repeat with the ORIGINAL reply, which
|
||||
// is what makes retrying a world write safe — before it, a lost acknowledgement
|
||||
// and a command that never applied were the same event seen from here.
|
||||
//
|
||||
// **A key is a function of the caller's unit of work, never of the attempt.** The
|
||||
// event runner derives it from `sha256(runId|stepId)`, so every retry of one step
|
||||
// carries the same key and a different step never collides with it. Passing a
|
||||
// fresh value per call would satisfy the type and defeat the entire mechanism.
|
||||
//
|
||||
// **The DELETEs deliberately take no key.** Their idempotency is inherent — the
|
||||
// second removal of a town-crier entry or a news article is a no-op the shard is
|
||||
// already happy to perform — and the sidecar builds those commands from the path
|
||||
// rather than from a body, so carrying one would be a protocol change bought for
|
||||
// a guarantee that already holds.
|
||||
//
|
||||
// A caller that sends no key gets exactly the pre-protocol-6 behaviour, which is
|
||||
// what leaves the admin screens (which send none, being driven by a human who can
|
||||
// see whether the thing happened) unchanged.
|
||||
//
|
||||
// One new status can now come back from a keyed write: **425**, the sidecar's
|
||||
// mapping of `bridge.busy` — a command under this key is still in flight on the
|
||||
// shard. It is transient and retryable, and `shardAnnounce.classify` already
|
||||
// treats it so by falling through to its retry case.
|
||||
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const log = require('../core').logger('uo-link-client')
|
||||
|
||||
const TIMEOUT_MS = 12000 // sidecar waits up to 10s on the shard before 504
|
||||
// The sidecar waits up to 10s on the shard before answering 504, so this sits
|
||||
// just above it — every call answers rather than being abandoned mid-flight.
|
||||
//
|
||||
// **Exported because the event actions are declared against it** (EVENTS_PLAN.md
|
||||
// Phase 9). An action's `budgetMs` must exceed this or core's dispatch deadline
|
||||
// fires first and classifies the step `retry` without asking the module, which
|
||||
// for a broadcast means announcing twice. `config/uoEventActions.js` states that
|
||||
// relationship and its test asserts it, and both need the number to come from
|
||||
// here rather than from a copy that can drift.
|
||||
const TIMEOUT_MS = 12000
|
||||
const CONFIG_TTL_MS = 5000
|
||||
|
||||
let cachedConfig = null
|
||||
@@ -159,15 +196,18 @@ const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
|
||||
})
|
||||
const unlinkAccount = ({ actor, account }) =>
|
||||
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
|
||||
const postTownCrier = ({ id, lines, durationSec }) =>
|
||||
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
||||
const postTownCrier = ({ id, lines, durationSec, idempotencyKey }) =>
|
||||
call('/towncrier', { method: 'POST', body: { id, lines, durationSec, idempotencyKey } })
|
||||
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
|
||||
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
|
||||
// in the in-game News window; re-posting the same id REPLACES it. `announce`
|
||||
// (default true on the sidecar) controls whether the criers proclaim the title.
|
||||
const postNews = ({ id, title, body, image, url, announce }) =>
|
||||
call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } })
|
||||
const postNews = ({ id, title, body, image, url, announce, idempotencyKey }) =>
|
||||
call('/news', {
|
||||
method: 'POST',
|
||||
body: { id: String(id), title, body, image, url, announce, idempotencyKey },
|
||||
})
|
||||
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
|
||||
// ── Staff write plane (§6) ─────────────────────────────────────────────────
|
||||
@@ -180,8 +220,8 @@ const adminBan = ({ actor, account, serial, durationSec, reason }) =>
|
||||
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
|
||||
const adminUnban = ({ actor, account }) =>
|
||||
call('/admin/unban', { method: 'POST', body: { actor, account } })
|
||||
const adminBroadcast = ({ actor, text, hue }) =>
|
||||
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
|
||||
const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
|
||||
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue, idempotencyKey } })
|
||||
|
||||
// ── Help-page (support) queue commands (§6) ────────────────────────────────
|
||||
const respondPage = (pageId, { message, close }) =>
|
||||
@@ -189,6 +229,7 @@ const respondPage = (pageId, { message, close }) =>
|
||||
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
|
||||
|
||||
module.exports = {
|
||||
TIMEOUT_MS,
|
||||
invalidateConfig,
|
||||
health,
|
||||
getCharBySerial,
|
||||
|
||||
Reference in New Issue
Block a user