feat(events): an event action's progress() on a live run's public page (MODULE_API 1.12.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 38s
PR Checks / client-build (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 20m42s

An event action may declare an optional `progress(envelope)`: how its
step stands while the run is live. Core accepts two shapes and
interprets neither:

- a count: { label, left, of }, rendered "Bandits: 3 of 8 left";
- a percentage: { label, percent }, rendered "The Juggernaut: 62%".

How core asks:

- `events/progress.js` asks each step of a live run whose action has
  one. It passes the step's own held ledger rows and the reader's
  account id, so the module applies its own visibility.
- Each call has a 2 s deadline. A throw, a late answer or any other
  shape is a line not shown, never an error on the page.
- One answer per run and reader is cached for 5 s.
- The public event's live occurrence carries the lines as `progress`.
  The route takes an optional session for the reader.
- The page renders them under the phase label and re-reads them every
  15 s while the event is live.

Core stays game-agnostic: no game's words are in it. Additive, so a
minor bump. Module-uo declares ^1.10.0, registers no action with
progress(), and its actions and pages are unchanged.

From RunicNPC stage 8 (D304, D305).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-06 02:47:00 -05:00
parent f0e7d2aa2a
commit 9ddb59a326
18 changed files with 623 additions and 17 deletions

View File

@@ -0,0 +1,188 @@
// ── A live run's progress lines ────────────────────────────────────────────
//
// EVENTS.md §I and MODULE_API 1.12.0. While a run is live, its public page can
// say how it stands — "Bandits: 3 of 8 left", "The Juggernaut: 62%" — and only
// the module that carried out a step knows the answer. So an action may declare
// `progress(envelope)`, and this file asks each step of the run whose action has
// one.
//
// **Core never interprets a line.** It accepts exactly two shapes, a count and a
// percentage, each with a label, and renders them as text. Core cannot know what
// a "bandit" is, and does not need to: it knows "label: left of total" and
// "label: percent", which serve any game, and a UO module could fill them for its
// own actions without a word of this file changing (D305).
//
// **Nothing here may hurt the page that asked.** A public event page is read by
// anybody, so every failure — a module that throws, answers late, answers
// nonsense or is uninstalled — is a line that is not shown, and a log line. The
// page always renders. That is `registerTeamProvider`'s posture rather than
// `dispatch.js`'s: a missing progress line costs a reader one sentence, and a page
// that errored would cost them the event.
//
// **One answer per run and viewer level, for a few seconds.** A busy page is
// read by many people at once, and each read reaching the game through a module
// would turn the audience into load. The module's own caches (Rust's map answer)
// sit behind this one; this one bounds how often core asks at all.
const registries = require('../modules/registries')
const stepsDb = require('../model/events/eventRunSteps.db')
const resourcesDb = require('../model/events/eventRunResources.db')
const { withDeadline } = require('./dispatch')
const log = require('../utils/logger')('events')
// The deadline for one `progress()` call. Short, because a reader is waiting on
// it: the page renders without a late line rather than waiting for it.
const PROGRESS_BUDGET_MS = 2000
// How long one run's lines are reused for, per viewer.
const CACHE_MS = 5000
// The bound on a label, in characters. A label is a phrase beside a number.
const MAX_LABEL = 80
// The bound on lines per run. A run with more Place steps than this has a page
// that is a list of numbers, and the cap keeps a module bug from becoming a wall.
const MAX_LINES = 20
// The ledger statuses that still mean "this run's".
const HELD = new Set(resourcesDb.HELD)
/**
* One module answer as a line, or null. Pure and exported: the two shapes are the
* contract, and anything else is dropped rather than repaired.
*/
function toLine(answer, actionId) {
if (answer === null || answer === undefined) return null
if (typeof answer !== 'object' || Array.isArray(answer)) {
log.warn('event progress is not an object', { action: actionId })
return null
}
const label = typeof answer.label === 'string' ? answer.label.trim() : ''
if (!label || label.length > MAX_LABEL) {
log.warn('event progress has a bad label', { action: actionId })
return null
}
const hasCount = answer.left !== undefined || answer.of !== undefined
const hasPercent = answer.percent !== undefined
if (hasCount === hasPercent) {
log.warn('event progress must be a count or a percent', { action: actionId })
return null
}
if (hasCount) {
const { left, of } = answer
if (!Number.isInteger(left) || !Number.isInteger(of) || left < 0 || of < 1 || left > of) {
log.warn('event progress has a bad count', { action: actionId })
return null
}
return { label, left, of }
}
const percent = Number(answer.percent)
if (typeof answer.percent !== 'number' || !Number.isFinite(percent) || percent < 0 || percent > 100) {
log.warn('event progress has a bad percent', { action: actionId })
return null
}
return { label, percent: Math.round(percent) }
}
/** run id + viewer key → { at, lines } and { pending } while one is in flight. */
const cache = new Map()
// A key per reader, so the map is pruned of answers past their window once it
// grows; an ended run's entries go with them.
const PRUNE_AT = 1000
function prune(now) {
if (cache.size < PRUNE_AT) return
for (const [key, entry] of cache) {
if (!entry.pending && now() - entry.at >= CACHE_MS) cache.delete(key)
}
}
/**
* The lines one step's action answers, or null. Never throws.
*/
async function stepLine(step, run, resources, viewer) {
const action = registries.eventAction(step.action_id)
if (!action || typeof action.progress !== 'function') return null
const envelope = {
runId: run.id,
stepId: step.id,
idempotencyKey: step.idempotency_key,
scope: run.scope || '',
params: step.params || {},
// The ledger rows this step made that are still the run's, as the module
// wrote them. Core adds nothing a module did not already hold.
resources: resources.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload })),
// Who is reading, as an account id or null. The module decides what its own
// visibility settings let that account see, from its own re-read of the row.
viewer: { userId: viewer.userId == null ? null : viewer.userId },
}
try {
const raw = await withDeadline(() => action.progress(envelope), PROGRESS_BUDGET_MS, action.id)
if (raw && raw.__timedOut) {
log.warn('event progress timed out', { action: action.id, run: run.id, step: step.id })
return null
}
return toLine(raw, action.id)
} catch (err) {
log.warn('event progress threw', { action: action.id, run: run.id, step: step.id, message: err.message })
return null
}
}
/**
* The progress lines for one live run, in step order. Resolves `[]` when there
* is nothing to say or anything went wrong; never rejects.
*
* `viewer.userId` is the reader's account id or null. The cache is keyed on it
* because what a module shows can depend on who is reading.
*/
async function forRun(run, { viewer = { userId: null }, now = Date.now } = {}) {
if (!run || run.id == null) return []
const key = `${run.id}|${viewer.userId == null ? '' : viewer.userId}`
const hit = cache.get(key)
if (hit && hit.lines && now() - hit.at < CACHE_MS) return hit.lines
if (hit && hit.pending) return hit.pending
prune(now)
const pending = (async () => {
let lines = []
try {
const steps = (await stepsDb.listForRun(run.id)).filter((s) => {
const action = registries.eventAction(s.action_id)
return action && typeof action.progress === 'function'
})
if (steps.length) {
const ledger = (await resourcesDb.forRun(run.id)).filter(
(r) => HELD.has(r.status) && r.kind !== resourcesDb.STEP_KIND,
)
const answers = await Promise.all(
steps.map((s) => stepLine(s, run, ledger.filter((r) => String(r.step_id) === String(s.id)), viewer)),
)
lines = answers.filter(Boolean).slice(0, MAX_LINES)
}
} catch (err) {
log.warn('event progress could not be read', { run: run.id, message: err.message })
lines = []
}
cache.set(key, { at: now(), lines })
return lines
})()
cache.set(key, { ...(hit || {}), pending })
try {
return await pending
} finally {
const entry = cache.get(key)
if (entry && entry.pending === pending) delete entry.pending
}
}
/** Test seam. */
function _reset() {
cache.clear()
}
module.exports = { forRun, toLine, PROGRESS_BUDGET_MS, CACHE_MS, MAX_LABEL, MAX_LINES, PRUNE_AT, _reset }