diff --git a/client/src/lib/eventCalendar.js b/client/src/lib/eventCalendar.js index fafff21..b2fbe4a 100644 --- a/client/src/lib/eventCalendar.js +++ b/client/src/lib/eventCalendar.js @@ -97,3 +97,13 @@ export function statusWord(status, scheduledFor, now = Date.now()) { const at = new Date(scheduledFor).getTime() return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled' } + +// A live run's progress line (MODULE_API 1.12.0), as text. Core accepts two +// shapes from a module and interprets neither: a count, "Bandits: 3 of 8 left", +// and a percentage, "The Juggernaut: 62%". Anything else renders nothing. +export function progressText(line) { + if (!line || typeof line.label !== 'string' || !line.label) return '' + if (Number.isInteger(line.left) && Number.isInteger(line.of)) return `${line.label}: ${line.left} of ${line.of} left` + if (typeof line.percent === 'number') return `${line.label}: ${line.percent}%` + return '' +} diff --git a/client/src/modules/version.js b/client/src/modules/version.js index ca9fe7d..a4fdab7 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,9 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.12.0 — an event action's `progress()`: a live run's public page shows how +// its steps stand. Server-side registration; the client half is core's own event +// page rendering the lines. This file bumps for the reason at the top. // 1.11.0 — `ctx.events.expired({ kind, ref })` and the ledger's `expired` status // (Rust PLAN_FIXES D183): a module may say the game ended a ledgered resource at // its own deadline. Server-side only; the run console, which is core's own page, @@ -78,4 +81,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.11.0' +export const MODULE_API_VERSION = '1.12.0' diff --git a/client/src/routes/public/EventPage.jsx b/client/src/routes/public/EventPage.jsx index a401b9c..05df855 100644 --- a/client/src/routes/public/EventPage.jsx +++ b/client/src/routes/public/EventPage.jsx @@ -14,13 +14,19 @@ // branch left a failed load spinning for ever with nothing on screen naming the // problem. Order matters, and the order is error first. +import { useEffect, useState } from 'react' import { useParams, useSearchParams, Link } from 'react-router-dom' import PublicLayout from '../../components/PublicLayout.jsx' import PageHeader from '../../components/PageHeader.jsx' import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx' import { useAsync } from '../../lib/useAsync.js' import { api } from '../../api/client.js' -import { eventDateTime, statusWord } from '../../lib/eventCalendar.js' +import { eventDateTime, statusWord, progressText } from '../../lib/eventCalendar.js' + +// How often a live event's progress lines are read again while the page is open. +// The server caches each run's answer for five seconds, so this is the reader's +// cadence, not load on the game. +const PROGRESS_REFRESH_MS = 15_000 export default function EventPage() { const { slug } = useParams() @@ -28,6 +34,27 @@ export default function EventPage() { const run = params.get('run') const { loading, error, data } = useAsync(() => api.publicEvent(slug, run), [slug, run]) + // The live occurrence's progress (MODULE_API 1.12.0), re-read on a timer while + // it is live. Only the lines are replaced: re-running the page's own fetch + // would blank the page to a spinner every fifteen seconds. + const [progress, setProgress] = useState(null) + const live = Boolean(data && data.event && data.event.live) + useEffect(() => { + setProgress(null) + if (!live) return undefined + let active = true + const timer = setInterval(() => { + api + .publicEvent(slug, run) + .then((fresh) => active && setProgress(fresh?.event?.current?.progress || [])) + .catch(() => {}) + }, PROGRESS_REFRESH_MS) + return () => { + active = false + clearInterval(timer) + } + }, [slug, run, live]) + if (error) { return ( @@ -91,6 +118,16 @@ export default function EventPage() { the event is never published. */} {event.current.phase || 'Under way'} + {/* How it stands, in the words the steps' modules chose. Core + renders the two shapes it accepts and interprets neither. */} + {(progress || event.current.progress || []).map((line, i) => { + const text = progressText(line) + return text ? ( +
+ {text} +
+ ) : null + })} ) : event.next ? ( <> diff --git a/client/test/eventCalendar.test.js b/client/test/eventCalendar.test.js index a38f6b7..2703415 100644 --- a/client/test/eventCalendar.test.js +++ b/client/test/eventCalendar.test.js @@ -9,7 +9,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { eventTime, eventDateTime, readerDayLabel, statusWord } from '../src/lib/eventCalendar.js' +import { eventTime, eventDateTime, readerDayLabel, statusWord, progressText } from '../src/lib/eventCalendar.js' // 2026-09-12T00:00Z is 2026-09-11 20:00 in New York — deliberately an instant // whose DATE differs between the two zones, which is what makes the split @@ -87,3 +87,11 @@ test('the other three words do not depend on the clock at all', () => { test('an unreadable instant falls to the past-tense word rather than throwing', () => { assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen') }) + +test('a progress line is a count or a percent, and nothing else renders (MODULE_API 1.12.0)', () => { + assert.equal(progressText({ label: 'Bandits', left: 3, of: 8 }), 'Bandits: 3 of 8 left') + assert.equal(progressText({ label: 'The Juggernaut', percent: 62 }), 'The Juggernaut: 62%') + assert.equal(progressText({ label: 'Odd' }), '') + assert.equal(progressText({ left: 1, of: 2 }), '') + assert.equal(progressText(null), '') +}) diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index 156fa8e..551fad4 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -1,6 +1,6 @@ { "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", - "moduleApiVersion": "1.11.0", + "moduleApiVersion": "1.12.0", "triggers": [ { "id": "event.phase.changed", diff --git a/server/routes.guards.json b/server/routes.guards.json index 5fcb829..cd96119 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -2464,9 +2464,10 @@ { "method": "GET", "path": "/api/v1/public/events/:slug", - "handlers": 2, + "handlers": 3, "gates": [ - "siteMode" + "siteMode", + "optionalAuth" ] }, { diff --git a/server/src/events/progress.js b/server/src/events/progress.js new file mode 100644 index 0000000..e572a56 --- /dev/null +++ b/server/src/events/progress.js @@ -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 } diff --git a/server/src/model/events/eventPublic.model.js b/server/src/model/events/eventPublic.model.js index 1425b9e..5b5818f 100644 --- a/server/src/model/events/eventPublic.model.js +++ b/server/src/model/events/eventPublic.model.js @@ -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)), diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index a837ae1..56b1eb4 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -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, } } diff --git a/server/src/modules/version.js b/server/src/modules/version.js index f8a5b35..eed56fe 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -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 } diff --git a/server/src/router/v1/public/events.controller.js b/server/src/router/v1/public/events.controller.js index a156300..b7abc13 100644 --- a/server/src/router/v1/public/events.controller.js +++ b/server/src/router/v1/public/events.controller.js @@ -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') diff --git a/server/src/router/v1/public/events.router.js b/server/src/router/v1/public/events.router.js index 7d451b7..0972975 100644 --- a/server/src/router/v1/public/events.router.js +++ b/server/src/router/v1/public/events.router.js @@ -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, ) diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index b1bc629..9e2387d 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -17348,7 +17348,7 @@ "Public · Events" ], "summary": "One event", - "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.", + "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.", "parameters": [ { "name": "slug", @@ -17393,7 +17393,16 @@ "503": { "description": "Service Unavailable" } - } + }, + "security": [ + {}, + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] } }, "/api/v1/public/modules": { @@ -26751,6 +26760,100 @@ "example": true } } + }, + "progress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "On the live occurrence only: how its steps stand, in step order, in the words their modules chose (MODULE_API 1.12.0). Each line is a `label` with either `left` of `of` (\"Bandits: 3 of 8 left\") or a `percent` (\"The Juggernaut: 62%\"). Core renders them as text and interprets nothing. Empty when no step has anything to say, or its module did not answer in time." + }, + "items": { + "$ref": "#/components/schemas/PublicEventProgressLine" + } + } + } + } + } + } + }, + "PublicEventProgressLine": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One line of a live run’s progress: a `label` with either `left` of `of`, or a `percent`. Never both." + }, + "properties": { + "type": "object", + "properties": { + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Bandits" + } + } + }, + "left": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3 + } + } + }, + "of": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 8 + } + } + }, + "percent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "minimum": { + "type": "number", + "example": 0 + }, + "maximum": { + "type": "number", + "example": 100 + }, + "example": { + "type": "number", + "example": 62 + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 9601cb7..fcb74b2 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -1318,6 +1318,23 @@ const doc = { 'The LABEL of the phase a live run is in, resolved from the version the run pinned. Null unless it is live. The plan behind the event — phases, steps, actions and their params — is never published.', }, resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true }, + progress: { + type: 'array', + description: + 'On the live occurrence only: how its steps stand, in step order, in the words their modules chose (MODULE_API 1.12.0). Each line is a `label` with either `left` of `of` ("Bandits: 3 of 8 left") or a `percent` ("The Juggernaut: 62%"). Core renders them as text and interprets nothing. Empty when no step has anything to say, or its module did not answer in time.', + items: { $ref: '#/components/schemas/PublicEventProgressLine' }, + }, + }, + }, + PublicEventProgressLine: { + type: 'object', + description: + 'One line of a live run’s progress: a `label` with either `left` of `of`, or a `percent`. Never both.', + properties: { + label: { type: 'string', example: 'Bandits' }, + left: { type: 'integer', example: 3 }, + of: { type: 'integer', example: 8 }, + percent: { type: 'integer', minimum: 0, maximum: 100, example: 62 }, }, }, PublicEventParticipant: { diff --git a/server/test/eventActionRegistry.test.js b/server/test/eventActionRegistry.test.js index 30e0e94..095d9b9 100644 --- a/server/test/eventActionRegistry.test.js +++ b/server/test/eventActionRegistry.test.js @@ -187,6 +187,16 @@ test("reversible: 'ledger' without revert() is refused at registration", () => { assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function') }) +test('progress() is optional, must be a function, and never reaches the catalog (1.12.0)', () => { + assert.throws(() => register('demo', [ok({ progress: { label: 'x' } })]), /progress must be a function/) + register('demo', [ok({ progress: async () => ({ label: 'Things', left: 1, of: 2 }) })]) + assert.equal(typeof registries.eventAction('demo.thing.do').progress, 'function') + assert.equal(registries.allEventActions().find((a) => a.id === 'demo.thing.do').progress, undefined) + registries._reset() + register('demo', [ok()]) + assert.equal(registries.eventAction('demo.thing.do').progress, null) +}) + test('perform() is required and cost must be a function', () => { assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/) assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/) diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js index 3d9c496..03210bb 100644 --- a/server/test/eventModuleContract.test.js +++ b/server/test/eventModuleContract.test.js @@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => { // 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the // assertion that says it does. The number itself is pinned in version.js. assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION) - assert.equal(MODULE_API_VERSION, '1.11.0') + assert.equal(MODULE_API_VERSION, '1.12.0') }) test('a module registers actions, budgets, leases and option sources', () => { diff --git a/server/test/eventProgress.test.js b/server/test/eventProgress.test.js new file mode 100644 index 0000000..6dd91d6 --- /dev/null +++ b/server/test/eventProgress.test.js @@ -0,0 +1,174 @@ +// ── A live run's progress lines (MODULE_API 1.12.0) ──────────────────────── +// +// `events/progress.js`: core asks each step whose action declares `progress()` +// how it stands, and publishes two shapes it never interprets. What is worth +// testing is the contract's edges: +// +// • only the two shapes pass, a count and a percentage, each with a label; +// anything else is dropped, never repaired +// • a module that throws, answers late or is uninstalled costs a line, not the +// page: `forRun` never rejects +// • a step is asked with ITS ledger rows only, still held, and the reader's id +// • one answer per run and reader for the cache window +// +// Stubbed at the `.db` layer and the registry, like `eventPublic.test.js`. + +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const progress = require('../src/events/progress') +const registries = require('../src/modules/registries') +const stepsDb = require('../src/model/events/eventRunSteps.db') +const resourcesDb = require('../src/model/events/eventRunResources.db') +const db = require('../src/utils/db') + +after(() => db.close()) + +const originals = { steps: { ...stepsDb }, resources: { ...resourcesDb } } + +let steps +let ledger + +beforeEach(() => { + registries._reset() + progress._reset() + steps = [] + ledger = [] + stepsDb.listForRun = async () => steps + resourcesDb.forRun = async () => ledger +}) + +afterEach(() => { + Object.assign(stepsDb, originals.steps) + Object.assign(resourcesDb, originals.resources) + registries._reset() + progress._reset() +}) + +const register = (entries) => { + const api = registries.stage('demo') + api.registerEventActions(entries) + registries.apply(api.staged) +} + +const action = (over = {}) => ({ + id: 'demo.npc.place', + label: 'Place', + risk: 'change', + reversible: 'ledger', + perform: async () => ({ ok: true }), + revert: async () => ({ ok: true }), + ...over, +}) + +const step = (id, actionId = 'demo.npc.place', params = {}) => ({ + id, + action_id: actionId, + params, + idempotency_key: `key-${id}`, +}) + +const RUN = { id: 7, scope: '' } + +test('only a count or a percent, each with a label, is a line', () => { + assert.deepEqual(progress.toLine({ label: 'Bandits', left: 3, of: 8 }, 'a'), { label: 'Bandits', left: 3, of: 8 }) + assert.deepEqual(progress.toLine({ label: 'The Juggernaut', percent: 61.6 }, 'a'), { label: 'The Juggernaut', percent: 62 }) + assert.equal(progress.toLine(null, 'a'), null) + assert.equal(progress.toLine('3 of 8', 'a'), null) + assert.equal(progress.toLine({ left: 3, of: 8 }, 'a'), null, 'no label') + assert.equal(progress.toLine({ label: 'x'.repeat(progress.MAX_LABEL + 1), left: 1, of: 1 }, 'a'), null) + assert.equal(progress.toLine({ label: 'Both', left: 1, of: 2, percent: 50 }, 'a'), null) + assert.equal(progress.toLine({ label: 'More left than placed', left: 9, of: 8 }, 'a'), null) + assert.equal(progress.toLine({ label: 'Nothing placed', left: 0, of: 0 }, 'a'), null) + assert.equal(progress.toLine({ label: 'Half', left: 1.5, of: 3 }, 'a'), null) + assert.equal(progress.toLine({ label: 'Over', percent: 101 }, 'a'), null) + assert.equal(progress.toLine({ label: 'Text', percent: '50' }, 'a'), null) + // Extra keys do not ride along: core publishes what it validated. + assert.deepEqual(progress.toLine({ label: 'B', left: 0, of: 1, secret: 'x' }, 'a'), { label: 'B', left: 0, of: 1 }) +}) + +test('each step is asked with its own held rows and the reader, in step order', async () => { + const seen = [] + register([ + action({ + progress: async (env) => { + seen.push(env) + return { label: `Step ${env.stepId}`, left: env.resources.length, of: 2 } + }, + }), + ]) + steps = [step(1), step(2)] + ledger = [ + { step_id: 1, kind: 'npc', ref: 'a', payload: null, status: 'confirmed' }, + { step_id: 1, kind: 'npc', ref: 'b', payload: null, status: 'reverted' }, + { step_id: 2, kind: 'npc', ref: 'c', payload: null, status: 'confirmed' }, + { step_id: 2, kind: 'npc', ref: 'd', payload: null, status: 'pending' }, + { step_id: 2, kind: resourcesDb.STEP_KIND, ref: '2', payload: null, status: 'confirmed' }, + ] + const lines = await progress.forRun(RUN, { viewer: { userId: 42 } }) + assert.deepEqual(lines, [ + { label: 'Step 1', left: 1, of: 2 }, + { label: 'Step 2', left: 2, of: 2 }, + ]) + assert.deepEqual(seen[0].resources, [{ kind: 'npc', ref: 'a', payload: null }]) + assert.deepEqual(seen[0].viewer, { userId: 42 }) + assert.equal(seen[0].idempotencyKey, 'key-1') +}) + +test('a step whose action has no progress() is not asked, and says nothing', async () => { + register([action(), action({ id: 'demo.zone.open', reversible: 'none', revert: undefined })]) + steps = [step(1), step(2, 'demo.zone.open'), step(3, 'demo.uninstalled.thing')] + assert.deepEqual(await progress.forRun(RUN), []) +}) + +test('a module that throws, answers late or answers nonsense costs a line, never the page', async () => { + register([ + action({ + progress: async (env) => { + if (env.stepId === 1) throw new Error('sidecar down') + if (env.stepId === 2) return new Promise(() => {}) + if (env.stepId === 3) return { label: 'Bad', left: -1, of: 2 } + return { label: 'Good', percent: 40 } + }, + }), + ]) + steps = [step(1), step(2), step(3), step(4)] + const started = Date.now() + const lines = await progress.forRun(RUN) + assert.deepEqual(lines, [{ label: 'Good', percent: 40 }]) + assert.ok(Date.now() - started < progress.PROGRESS_BUDGET_MS + 1500, 'the deadline held') +}) + +test('a database that fails is an empty list, not a rejection', async () => { + register([action({ progress: async () => ({ label: 'x', left: 1, of: 1 }) })]) + stepsDb.listForRun = async () => { + throw new Error('pool closed') + } + assert.deepEqual(await progress.forRun(RUN), []) +}) + +test('one answer per run and reader within the window', async () => { + let asked = 0 + register([ + action({ + progress: async () => { + asked++ + return { label: 'Bandits', left: 3, of: 8 } + }, + }), + ]) + steps = [step(1)] + let t = 1_000_000 + const now = () => t + await progress.forRun(RUN, { now }) + await progress.forRun(RUN, { now }) + assert.equal(asked, 1, 'cached for the same reader') + await progress.forRun(RUN, { viewer: { userId: 5 }, now }) + assert.equal(asked, 2, 'a different reader is asked for') + t += progress.CACHE_MS + await progress.forRun(RUN, { now }) + assert.equal(asked, 3, 'asked again once the window passed') +}) diff --git a/server/test/eventPublic.test.js b/server/test/eventPublic.test.js index f94b2fc..c0437a8 100644 --- a/server/test/eventPublic.test.js +++ b/server/test/eventPublic.test.js @@ -343,6 +343,31 @@ test('a live run carries its phase LABEL, and never the spec behind it', async ( assert.equal(JSON.stringify(page).includes('core.announce'), false) }) +test('only the live occurrence carries progress, asked for the reader (1.12.0)', async () => { + const progress = require('../src/events/progress') + const original = progress.forRun + const asked = [] + progress.forRun = async (run, { viewer }) => { + asked.push({ run: run.id, viewer }) + return [{ label: 'Bandits', left: 3, of: 8 }] + } + try { + store.runs[0].status = 'running' + const page = await publicModel.event('the-yew-invasion', { userId: 42 }) + assert.deepEqual(page.event.current.progress, [{ label: 'Bandits', left: 3, of: 8 }]) + assert.deepEqual(asked, [{ run: 3692, viewer: { userId: 42 } }]) + for (const o of [...page.event.upcoming, ...page.event.past]) assert.equal(o.progress, undefined) + + store.runs[0].status = 'completed' + asked.length = 0 + const done = await publicModel.event('the-yew-invasion') + assert.equal(done.event.current, null) + assert.equal(asked.length, 0, 'a finished run is not asked') + } finally { + progress.forRun = original + } +}) + test('a phase the pinned version does not name renders nothing rather than an id', async () => { store.runs[0].status = 'running' store.runs[0].current_phase = 'a-phase-since-renamed'