// ── 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 }