feat(events): an event action's progress() on a live run's public page (MODULE_API 1.12.0)
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:
188
server/src/events/progress.js
Normal file
188
server/src/events/progress.js
Normal 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 }
|
||||
@@ -36,6 +36,7 @@ const versionsDb = require('./eventVersions.db')
|
||||
const participantsDb = require('./eventRunParticipants.db')
|
||||
const calendarModel = require('./eventCalendar.model')
|
||||
const recurrence = require('../../events/recurrence')
|
||||
const progress = require('../../events/progress')
|
||||
|
||||
// The public calendar's window when a caller names neither end: a few days BACK
|
||||
// through a month out. A visitor arriving at /site/events wants "what is on", and
|
||||
@@ -289,7 +290,7 @@ const publicOccurrence = (run, spec) => ({
|
||||
* turn a stale link in a months-old mail into a dead end rather than a page
|
||||
* about the thing the mail was about.
|
||||
*/
|
||||
async function event(slug, { runId = null } = {}) {
|
||||
async function event(slug, { runId = null, userId = null } = {}) {
|
||||
const definition = await definitionsDb.getPublicBySlug(String(slug || ''))
|
||||
if (!definition) return { ok: false, status: 404, errors: ['Not found'] }
|
||||
|
||||
@@ -339,6 +340,13 @@ async function event(slug, { runId = null } = {}) {
|
||||
const specRun = named || live[0] || null
|
||||
const spec = specRun ? (await versionsDb.getById(specRun.version_id))?.spec || null : null
|
||||
|
||||
// How the live occurrence stands, in the words its steps' modules chose
|
||||
// (MODULE_API 1.12.0). Only for a live run: a finished one has results, and a
|
||||
// scheduled one has nothing to count yet. `progress.forRun` never rejects and
|
||||
// answers `[]` when nobody has anything to say.
|
||||
const current = live[0] ? publicOccurrence(live[0], spec) : null
|
||||
if (current) current.progress = await progress.forRun(live[0], { viewer: { userId } })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -351,7 +359,7 @@ async function event(slug, { runId = null } = {}) {
|
||||
timezone: definition.timezone,
|
||||
series: series ? { name: series.name, slug: series.slug } : null,
|
||||
live: live.length > 0,
|
||||
current: live[0] ? publicOccurrence(live[0], spec) : null,
|
||||
current,
|
||||
next: next ? publicOccurrence(next, spec) : null,
|
||||
upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
|
||||
past: past.map((r) => publicOccurrence(r, spec)),
|
||||
|
||||
@@ -435,14 +435,14 @@ async function resolveAudience(id, params = {}) {
|
||||
/**
|
||||
* Every declaration WITHOUT its callables — what the admin catalog serves.
|
||||
*
|
||||
* `perform`, `revert`, `reconcile` and `cost` are stripped for the same reason `resolve` is
|
||||
* `perform`, `revert`, `reconcile`, `cost` and `progress` are stripped for the same reason `resolve` is
|
||||
* stripped from an audience and `handler` from a slash command: this is the
|
||||
* object that leaves the process, and the browser's whole relationship with an
|
||||
* action is naming one by id. §F's "a module registers actions server-side and
|
||||
* adds no routes for them" is only true if the functions never ride out.
|
||||
*/
|
||||
const allEventActions = () =>
|
||||
[...eventActions.values()].map(({ perform, revert, reconcile, cost, ...rest }) => rest)
|
||||
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, ...rest }) => rest)
|
||||
|
||||
/** One declaration, callables included. The runner's lookup (Phase 2). */
|
||||
const eventAction = (id) => eventActions.get(id) || null
|
||||
@@ -1041,7 +1041,7 @@ function checkActionParam(actionId, entry, seen) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile }])`.
|
||||
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress }])`.
|
||||
*
|
||||
* A typed verb core may ask a registrant to carry out. Everything decidable from
|
||||
* the argument alone is decided here, at the call; the collision — is this id
|
||||
@@ -1118,6 +1118,12 @@ function checkEventActionShape(entry) {
|
||||
if (a.cost !== undefined && typeof a.cost !== 'function') {
|
||||
throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
|
||||
}
|
||||
// 1.12.0, optional: how a live run's step stands, for its public page
|
||||
// (`events/progress.js`). Any action may declare it; an action that ledgers
|
||||
// nothing can still say something about the world it changed.
|
||||
if (a.progress !== undefined && typeof a.progress !== 'function') {
|
||||
throw new Error(`registerEventActions: ${a.id} progress must be a function`)
|
||||
}
|
||||
|
||||
const version = a.version === undefined ? 1 : a.version
|
||||
if (!Number.isInteger(version) || version < 1) {
|
||||
@@ -1150,6 +1156,7 @@ function checkEventActionShape(entry) {
|
||||
perform: a.perform,
|
||||
revert: a.revert || null,
|
||||
reconcile: a.reconcile || null,
|
||||
progress: a.progress || null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.12.0 — an event action may declare `progress(envelope)`: how a live run's
|
||||
// step stands, for its public page (docs/website/EVENTS.md §I; RunicNPC D304,
|
||||
// D305). Core accepts two shapes, `{ label, left, of }` and `{ label, percent }`,
|
||||
// puts them in the public run as `progress` and renders them as text it does not
|
||||
// interpret. Optional and additive, so minor: Module-uo declares `^1.10.0` and
|
||||
// registers no action with it, so its actions and pages are unchanged.
|
||||
//
|
||||
// 1.11.0 — `ctx.events.expired({ kind, ref })`, and `expired` as a resource
|
||||
// ledger status (docs/website/EVENTS.md §L; Rust PLAN_FIXES D183). A game that
|
||||
// ends something at its own deadline — a Rust zone erased when its time is up —
|
||||
@@ -167,6 +174,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.11.0'
|
||||
const MODULE_API_VERSION = '1.12.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -50,7 +50,12 @@ async function getEvent(req, res) {
|
||||
// string: the model matches it against this definition's own runs and
|
||||
// ignores anything else, so a garbage value renders the page rather than an
|
||||
// error. See the model's note on why it is not refused.
|
||||
const result = await events.event(req.params.slug, { runId: req.query.run || null })
|
||||
// `userId` is the reader's, when they sent a session (`optionalAuth`): a
|
||||
// module's progress line may show a signed-in reader more than a stranger.
|
||||
const result = await events.event(req.params.slug, {
|
||||
runId: req.query.run || null,
|
||||
userId: req.user && req.user.id != null ? req.user.id : null,
|
||||
})
|
||||
return answer(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public event')
|
||||
|
||||
@@ -15,6 +15,7 @@ const express = require('express')
|
||||
|
||||
const ctrl = require('./events.controller')
|
||||
const siteMode = require('../../../middleware/siteMode')
|
||||
const { optionalAuth } = require('../../../auth/session.middleware')
|
||||
|
||||
const eventsRouter = express.Router()
|
||||
|
||||
@@ -48,12 +49,14 @@ eventsRouter.get(
|
||||
'/:slug',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'One event'
|
||||
// #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.'
|
||||
// #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more, and `progress`: how its steps stand, each line a label with either `left` of `of` or a `percent`, in the words the steps\' modules chose. What a module shows may depend on who is reading, so sending a session is optional and may widen it.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The event slug.' }
|
||||
// #swagger.parameters['run'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Which occurrence the results are about — what an announcement\'s link carries, so a mail about last Friday does not open next Friday\'s. A run that does not belong to this event is ignored rather than refused.' }
|
||||
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The event', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEvent" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such public event', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
optionalAuth,
|
||||
ctrl.getEvent,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user