// ── Core's own event actions ─────────────────────────────────────────────── // // EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js // and registered through the same staging area a module will use in Phase 7 — // which is the entire reason these three exist this early. A registry whose first // real registrant is a module is a registry that has already drifted, and §F's // claim that core is "an event engine that can announce, wait, cue a human and // publish results" with NO module installed is only true if core declares the // verbs that do it. // // **Three actions, and between them they cover the three things an event can do // that name no game noun at all**: tell people something, let time pass, and ask // a human to go and do something. A deployment with no game module installed has // a working event system made of exactly these. // // **Phase 8 added a fourth, and it is the odd one out on purpose.** `core.lease` // names no game noun either — it borrows a value some module declared — but // unlike the other three it genuinely changes the world, so it is `risk: 'change'` // and therefore default-off, admin-only and cap-checked like any module verb. // It is CORE's rather than each module's because §F puts the duration bound and // the two-events-one-target conflict check on core's side of the seam: a lease // verb per module would be that bound re-implemented once per module, advisory // everywhere, and wrong in the first one that forgot it. // // **Phase 2 gave all three real bodies**, and between them they exercise every // shape §F's envelope can take: `core.announce` does work and finishes, // `core.wait` finishes while deferring what follows it, and `core.cue` succeeds // without finishing at all. The runner learns nothing about any of them by id — // each says what it needs in the envelope, through the same two members Phase 7 // hands to a module. // // **This file must not touch the database.** It is required from `registerCore()`, // which runs under `routeManifest.js` and `swagger.js` against a dead pool // (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is // looked up inside `perform()`, per call, which is also what makes a leg // registered by a module that booted later reachable at all. `core.lease` is the // one action here that reaches a table, and it requires the model INSIDE // `perform()` for the same reason — a top-level require would make this file // build a pool during route-manifest generation. const registries = require('../modules/registries') /** * Turn the `value` param's text into whatever the named lease says it holds. * * The range check is here too, and it is REQUIRED on the numeric types for the * reason §F gives: unlike a cap, a bad lease value is in force the moment it is * applied, so "0.5 to 5" is not advice. */ function coerceLeaseValue(lease, raw) { const text = String(raw === undefined || raw === null ? '' : raw).trim() if (lease.type === 'string') return { ok: true, value: text } if (lease.type === 'bool') { if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true } if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false } return { ok: false, error: `"${raw}" is not a yes or no value for ${lease.label}` } } const n = Number(text) if (text === '' || !Number.isFinite(n)) { return { ok: false, error: `"${raw}" is not a number, and ${lease.label} holds one` } } if (lease.type === 'int' && !Number.isInteger(n)) { return { ok: false, error: `${lease.label} holds a whole number, and "${raw}" is not one` } } if (n < lease.min || n > lease.max) { return { ok: false, error: `${lease.label} accepts ${lease.min} to ${lease.max}, and "${raw}" is outside that` } } return { ok: true, value: n } } const ACTIONS = [ { id: 'core.announce', label: 'Announce', description: 'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.', // Nothing in the world changes and nothing is created: a message goes out. // That is what makes the default `on_failure` for this step `retry -> skip` // (§L) rather than `pause`, and it is the honest class even though the // message itself cannot be unsent. risk: 'notify', // A sent announcement is gone. `none` rather than `ledger` is not an // omission — there is no undo to write, and declaring `ledger` would put a // row in the cleanup ledger that teardown could never resolve. reversible: 'none', version: 1, params: [ { // A leg id, checked against the announce-leg registry at dispatch rather // than here: legs are registered by modules, and this file is evaluated // before any module has registered anything. // // **`source` is what moves that check earlier** (Phase 7). The dispatch // check stays — a module can boot between authoring and the run — but // until now a typo here was caught mid-run and nowhere else, which is the // defect Phase 6's walk hit: an announce leg "site" no module registers, // found by a dry run rather than by the form that accepted it. name: 'leg', type: 'string', required: true, example: 'discord', source: 'core.options.legs', description: 'The announce leg to publish on. Registered legs only.', }, { name: 'title', type: 'string', required: false, example: 'The gates of Britain open at dusk', description: 'Optional heading, for legs that render one.', }, { name: 'body', type: 'string', required: true, example: 'A caravan has been sighted on the road east of Cove.', description: 'The announcement itself. Plain text.', }, ], /** * Publish through the announce leg the step names. * * **The legs are reused rather than reimplemented** (§J, "reuse the legs"): * `discord` is core's and `towncrier` is module-uo's, both already registered, * both already carrying a `classify()` that knows what their transport's * failures mean. An event announcement that went out by some other path would * be a second delivery mechanism with its own bugs. * * A leg's `dispatch()` takes a POST — that is the shape the news path gave it * — so an event announcement is presented as one. `excerpt` is the body * because it is the field every leg renders as prose, and `image_url` is null * because an event announcement has no article behind it to illustrate. * Widening the leg contract to carry a second payload shape is a * MODULE_API change, and Phase 7 is where those are made. * * The leg id is checked HERE rather than at authoring time, and that is not * laxness: legs are registered by modules, and a spec is validated in a * process that may have booted before the module that owns the leg. */ async perform({ params, verify }) { const registered = registries.announceLeg(params.leg) if (!registered) { // Terminal, not transient. A leg nobody registers will not appear // between two attempts sixty seconds apart, and the honest cause — a // module removed, or a typo the authoring form could not catch — is a // thing a human fixes. return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` } } // A dry run reports what it WOULD do and sends nothing (§I). Answering // before the dispatch rather than inside the leg is what keeps that true // for legs written by people who never read this file. if (verify) return { ok: true } const result = await registered.dispatch({ title: params.title || null, excerpt: params.body, image_url: null, }) // The leg's own classification, not a second opinion. `retry` vs // `terminal` for a Discord webhook is a judgement `discordAnnounce.classify` // already makes, and making it twice is how the two drift. const { outcome, error } = registered.classify(result) if (outcome === 'done') return { ok: true } return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` } }, }, { id: 'core.wait', label: 'Wait', description: 'Let a fixed amount of time pass before the next step of this phase runs.', // `inspect` rather than `notify`: nothing is sent and nobody is told. It is // the weakest class the closed set has for an action that is not a broadcast. risk: 'inspect', reversible: 'none', version: 1, params: [ { name: 'seconds', type: 'int', required: true, example: 300, description: 'How long to wait. The runner sets the next step due_at from this.', }, ], // A wait is a genuine no-op at dispatch, and it stayed one: the delay is the // NEXT step's `due_at`, which the runner owns, not something this function // sleeps through. A `perform` that slept would hold a step's claim for the // duration and turn a five-minute pause into a five-minute lease — and the // reclaim would then re-dispatch it, so a long enough wait would never end. // // `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is // why the runner can honour this without knowing what `core.wait` is. async perform({ params, verify }) { if (verify) return { ok: true } return { ok: true, holdFor: params.seconds } }, }, { id: 'core.cue', label: 'Cue a human', description: 'Post an instruction for staff and wait for someone to confirm it was done before the run advances.', // The action itself only posts an instruction. Whatever the human then does // is outside this system entirely, which is precisely why the cue exists: // it is how an event uses a capability no module has automated. risk: 'notify', reversible: 'none', version: 1, params: [ { name: 'instruction', type: 'string', required: true, example: 'Open the north gate and read the herald script in Britain bank.', description: 'What the staff member is being asked to do.', }, { name: 'assignee', type: 'string', required: false, example: 'Event Team', description: 'Who the cue is addressed to. A label, not an account.', }, ], /** * Post the instruction and PARK. The step does not complete here. * * `await: 'human'` is the envelope member that says so (org lead, * 2026-09-02), and the runner's answer to it is to leave the step `running` * with a NULL lease — genuinely in flight, nothing holding it, so the stale * reclaim passes it by and a cue posted on Friday is still waiting on Monday. * The step ends when someone presses confirm, which is Phase 3's control. * * **Nothing is delivered from here in Phase 2, and that is visible rather * than pretended.** The instruction is carried by the step's own params and * shown on the run console; routing it to Discord or to a staff inbox is * Phase 10's integration work, through the engagement triggers that own every * other notification on this platform. An action that grew its own delivery * path would be the second one. */ async perform({ verify }) { if (verify) return { ok: true } return { ok: true, await: 'human' } }, }, { id: 'core.lease', label: 'Borrow a value', description: 'Hold a module-declared value at a new setting for a bounded time, and put the old one back at teardown.', // The world changes and it changes back, so `change` rather than // `irreversible` — and `change`'s default `on_failure` is `pause`, which is // the right stop for a run that failed halfway through altering the world. risk: 'change', // The one action core ships in this class. `override` is what tells the // cleanup sweep to restore through the LEASE registry rather than through an // action's `revert()`, which is why this action needs no `revert()` of its own // and why the registry refuses one on it. reversible: 'override', version: 1, params: [ { name: 'lease', type: 'string', required: true, example: 'uo.rate.skillgain', source: 'core.options.leases', description: 'Which declared value to borrow.', }, { // **A string, and the coercion is here rather than in the type system.** // A param declares ONE type; a lease declares its own, and they are four // different ones. Typing this `float` would make a boolean lease // unauthorable and a string lease nonsense, so the field takes text and // this action turns it into whatever the named lease said it holds — the // one place that knows both halves. name: 'value', type: 'string', required: true, example: '3.0', description: 'What to hold it at, in whatever type the lease declares.', }, { name: 'minutes', type: 'int', required: true, example: 120, description: 'How long to hold it. Core refuses more than the lease allows.', }, ], // What a lease costs is the LEASE's business to bound, not a budget's: // `maxDurationMs` and the numeric range are declared beside the callables and // enforced below. A cap dimension here would be core inventing an accounting // unit for something a module already bounds — and `registerEventBudgets` // refuses a dimension nobody declared, which is exactly the rule that would // then bite core's own action. /** * Read the baseline, reserve the target, apply the value. * * **This is rule 1 in its strongest form.** Unlike a spawn, a lease's target * is knowable before the dispatch — it is the lease id the step names — so * the ledger row is written with its real `kind` and `ref` BEFORE anything * touches the world, and the two-events-one-target refusal comes from the * unique index at that moment rather than from a check that read and then * wrote. A second run asking for a lease another run holds comes back * `refused`, in the same words a cap breach uses and for the same reason: * nothing is broken, the deployment already has that value spoken for. * * The order is read then reserve then apply, and a failure at each stage * undoes the one before it: a reservation whose `apply` refuses is released * here rather than left for the sweep, because there is nothing out there to * give back and a shard that is merely down must not lock a lease out for the * length of a retry cycle. */ async perform({ runId, stepId, params, verify }) { // eslint-disable-next-line global-require const resourcesDb = require('../model/events/eventRunResources.db') const lease = registries.eventLease(params.lease) if (!lease) { return { ok: false, retry: false, error: `no module registers the lease "${params.lease}"` } } const coerced = coerceLeaseValue(lease, params.value) if (!coerced.ok) return { ok: false, retry: false, error: coerced.error } const minutes = Number(params.minutes) if (!Number.isFinite(minutes) || minutes <= 0) { return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` } } const ms = Math.round(minutes * 60_000) if (ms > lease.maxDurationMs) { return { ok: false, retry: false, error: `${lease.label} may be held for at most ${Math.floor(lease.maxDurationMs / 60_000)} minutes, not ${minutes}`, } } // **The dry run stops here, and it has still checked everything worth // checking**: the lease exists, the value is in range and the duration is // allowed. What it deliberately does not do is reserve the target — a // verify that took a lease would be a dry run that changed something, and // it would then refuse the real run that followed it. if (verify) return { ok: true } const baseline = await lease.read() if (!baseline || baseline.ok !== true) { return { ok: false, error: `could not read the current value of ${lease.label}` } } const until = new Date(Date.now() + ms) const reserved = await resourcesDb.reserve({ runId, stepId, owner: lease.owner || 'core', kind: 'override', ref: lease.id, payload: { target: lease.id, baseline: baseline.value, applied: coerced.value, until: until.toISOString() }, leaseUntil: until, }) if (!reserved.ok) { const heldBy = reserved.holder ? ` (run ${reserved.holder.run_id})` : '' return { ok: false, retry: false, error: `${lease.label} is already leased by another run${heldBy}`, } } // **`until` goes down the wire** (§F). The module passes it to its sidecar // and the game side restores baseline when it passes, without being asked // again — the fail-safe that makes an unattended, scheduled world change // defensible, because the worst case is a world back at baseline early // rather than one stuck changed indefinitely. let applied try { applied = await lease.apply(coerced.value, until) } catch (err) { applied = { ok: false, error: err.message } } if (!applied || applied.ok !== true) { await resourcesDb.markReverted(reserved.id) return { ok: false, error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value` } } await resourcesDb.confirm(reserved.id) // **The run now owes the world something, and something has to say so.** // The generic path marks a run dirty when it records a module's reported // resources; this action reserves its own row and never goes through it, so // a run whose only resource was a lease would have kept `cleanup_status = // 'not_required'` and never been swept. Found by the live walk, and the // cleanup leg's own scan was widened to make the class impossible rather // than only this instance. // eslint-disable-next-line global-require await require('../events/ledger').markRunDirty(runId) return { ok: true } }, }, ] // ── Core's own param option sources (§F, Phase 7) ────────────────── // // One, and it is core's half of the seam it hands a module on the same boot: a // param's `source` names a registered option source, core asks it for values, and // the authoring form renders a dropdown instead of a text box. // // **The legs are already a registry with labels in it**, so this costs nothing // new — which is what makes it the right first exercise. `resolve()` is called // per request rather than read once, for the same reason `core.announce` looks a // leg up inside `perform()`: a leg registered by a module that booted after this // file was evaluated must still appear, and a module uninstalled since must stop // appearing. const OPTION_SOURCES = [ { id: 'core.options.legs', label: 'Announce legs', description: 'Every delivery leg registered on this deployment right now.', async resolve() { return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg })) }, }, { id: 'core.options.leases', label: 'Borrowable values', description: 'Every value a module has declared this deployment may lease.', async resolve() { return registries .allEventLeases() .map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] })) }, }, ] module.exports = { ACTIONS, OPTION_SOURCES }