Files
website/server/src/router/v1/public/events.controller.js
wtclaude 9ddb59a326
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
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
2026-10-06 02:47:00 -05:00

74 lines
2.8 KiB
JavaScript

// Public · Events — the anonymous event surface (EVENTS.md § API surface).
//
// Phase 14a. Three reads and no writes: the calendar, one event, one arc.
//
// **Every one of them is a thin pass-through to `eventPublic.model`, and that is
// deliberate.** The projection — which fields exist at all on a public entry — is
// the security boundary, and it belongs in one file rather than in three
// controllers that would each have to remember it. What is left here is the
// HTTP: parse the query, map the model's `status` onto a response code, and turn
// a thrown read into a 500 rather than a stack trace.
//
// **A 404 here means "no such public event"** and cannot be told from "no such
// slug at all". A draft, an archived definition and an unlisted one answer
// identically, which is the whole point: an operator who has not announced
// something has not announced its existence either.
const events = require('../../../model/events/eventPublic.model')
const log = require('../../../utils/logger')('public:events')
const fail = (res, err, what) => {
log.error(`${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
const answer = (res, result) =>
result.ok
? res.json(result)
: res.status(result.status || 400).json({ message: result.errors?.[0] || 'Bad Request', errors: result.errors })
async function getCalendar(req, res) {
try {
const seriesId = req.query.seriesId ? Number(req.query.seriesId) : null
if (req.query.seriesId && !Number.isInteger(seriesId)) {
return res.status(400).json({ message: 'seriesId must be an integer' })
}
const result = await events.calendar({
from: req.query.from || null,
to: req.query.to || null,
seriesId,
})
return answer(res, result)
} catch (err) {
return fail(res, err, 'public calendar')
}
}
async function getEvent(req, res) {
try {
// `run` is optional and un-validated beyond being carried through as a
// 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.
// `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')
}
}
async function getSeries(req, res) {
try {
return answer(res, await events.series(req.params.slug))
} catch (err) {
return fail(res, err, 'public series')
}
}
module.exports = { getCalendar, getEvent, getSeries }