Compare commits
1 Commits
main
...
feat/event
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ddb59a326 |
@@ -97,3 +97,13 @@ export function statusWord(status, scheduledFor, now = Date.now()) {
|
|||||||
const at = new Date(scheduledFor).getTime()
|
const at = new Date(scheduledFor).getTime()
|
||||||
return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
|
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 ''
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
// that the two files can drift, so a test asserts they agree
|
// that the two files can drift, so a test asserts they agree
|
||||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||||
// both.
|
// 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
|
// 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
|
// (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,
|
// 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
|
// 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
|
// 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.
|
// 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'
|
||||||
|
|||||||
@@ -14,13 +14,19 @@
|
|||||||
// branch left a failed load spinning for ever with nothing on screen naming the
|
// branch left a failed load spinning for ever with nothing on screen naming the
|
||||||
// problem. Order matters, and the order is error first.
|
// problem. Order matters, and the order is error first.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
import PageHeader from '../../components/PageHeader.jsx'
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
import { useAsync } from '../../lib/useAsync.js'
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
import { api } from '../../api/client.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() {
|
export default function EventPage() {
|
||||||
const { slug } = useParams()
|
const { slug } = useParams()
|
||||||
@@ -28,6 +34,27 @@ export default function EventPage() {
|
|||||||
const run = params.get('run')
|
const run = params.get('run')
|
||||||
const { loading, error, data } = useAsync(() => api.publicEvent(slug, run), [slug, 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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<PublicLayout section="website">
|
<PublicLayout section="website">
|
||||||
@@ -91,6 +118,16 @@ export default function EventPage() {
|
|||||||
the event is never published. */}
|
the event is never published. */}
|
||||||
{event.current.phase || 'Under way'}
|
{event.current.phase || 'Under way'}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 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 ? (
|
||||||
|
<div key={i} className="sans" style={{ marginTop: 4 }}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
) : event.next ? (
|
) : event.next ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
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
|
// 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
|
// 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', () => {
|
test('an unreadable instant falls to the past-tense word rather than throwing', () => {
|
||||||
assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen')
|
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), '')
|
||||||
|
})
|
||||||
|
|||||||
@@ -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.",
|
"_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": [
|
"triggers": [
|
||||||
{
|
{
|
||||||
"id": "event.phase.changed",
|
"id": "event.phase.changed",
|
||||||
|
|||||||
@@ -2464,9 +2464,10 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/events/:slug",
|
"path": "/api/v1/public/events/:slug",
|
||||||
"handlers": 2,
|
"handlers": 3,
|
||||||
"gates": [
|
"gates": [
|
||||||
"siteMode"
|
"siteMode",
|
||||||
|
"optionalAuth"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
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 participantsDb = require('./eventRunParticipants.db')
|
||||||
const calendarModel = require('./eventCalendar.model')
|
const calendarModel = require('./eventCalendar.model')
|
||||||
const recurrence = require('../../events/recurrence')
|
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
|
// 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
|
// 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
|
* turn a stale link in a months-old mail into a dead end rather than a page
|
||||||
* about the thing the mail was about.
|
* 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 || ''))
|
const definition = await definitionsDb.getPublicBySlug(String(slug || ''))
|
||||||
if (!definition) return { ok: false, status: 404, errors: ['Not found'] }
|
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 specRun = named || live[0] || null
|
||||||
const spec = specRun ? (await versionsDb.getById(specRun.version_id))?.spec || null : 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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -351,7 +359,7 @@ async function event(slug, { runId = null } = {}) {
|
|||||||
timezone: definition.timezone,
|
timezone: definition.timezone,
|
||||||
series: series ? { name: series.name, slug: series.slug } : null,
|
series: series ? { name: series.name, slug: series.slug } : null,
|
||||||
live: live.length > 0,
|
live: live.length > 0,
|
||||||
current: live[0] ? publicOccurrence(live[0], spec) : null,
|
current,
|
||||||
next: next ? publicOccurrence(next, spec) : null,
|
next: next ? publicOccurrence(next, spec) : null,
|
||||||
upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
|
upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
|
||||||
past: past.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.
|
* 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
|
* 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
|
* 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
|
* 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.
|
* adds no routes for them" is only true if the functions never ride out.
|
||||||
*/
|
*/
|
||||||
const allEventActions = () =>
|
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). */
|
/** One declaration, callables included. The runner's lookup (Phase 2). */
|
||||||
const eventAction = (id) => eventActions.get(id) || null
|
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
|
* 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
|
* 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') {
|
if (a.cost !== undefined && typeof a.cost !== 'function') {
|
||||||
throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
|
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
|
const version = a.version === undefined ? 1 : a.version
|
||||||
if (!Number.isInteger(version) || version < 1) {
|
if (!Number.isInteger(version) || version < 1) {
|
||||||
@@ -1150,6 +1156,7 @@ function checkEventActionShape(entry) {
|
|||||||
perform: a.perform,
|
perform: a.perform,
|
||||||
revert: a.revert || null,
|
revert: a.revert || null,
|
||||||
reconcile: a.reconcile || null,
|
reconcile: a.reconcile || null,
|
||||||
|
progress: a.progress || null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
// 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.
|
// 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
|
// 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
|
// 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 —
|
// 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
|
// 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
|
// 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.
|
// 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 }
|
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
|
// 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
|
// 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.
|
// 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)
|
return answer(res, result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(res, err, 'public event')
|
return fail(res, err, 'public event')
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const express = require('express')
|
|||||||
|
|
||||||
const ctrl = require('./events.controller')
|
const ctrl = require('./events.controller')
|
||||||
const siteMode = require('../../../middleware/siteMode')
|
const siteMode = require('../../../middleware/siteMode')
|
||||||
|
const { optionalAuth } = require('../../../auth/session.middleware')
|
||||||
|
|
||||||
const eventsRouter = express.Router()
|
const eventsRouter = express.Router()
|
||||||
|
|
||||||
@@ -48,12 +49,14 @@ eventsRouter.get(
|
|||||||
'/:slug',
|
'/:slug',
|
||||||
// #swagger.tags = ['Public · Events']
|
// #swagger.tags = ['Public · Events']
|
||||||
// #swagger.summary = 'One event'
|
// #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['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.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[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" } } } } */
|
/* #swagger.responses[404] = { description: 'No such public event', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
siteMode,
|
siteMode,
|
||||||
|
optionalAuth,
|
||||||
ctrl.getEvent,
|
ctrl.getEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17348,7 +17348,7 @@
|
|||||||
"Public · Events"
|
"Public · Events"
|
||||||
],
|
],
|
||||||
"summary": "One event",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "slug",
|
"name": "slug",
|
||||||
@@ -17393,7 +17393,16 @@
|
|||||||
"503": {
|
"503": {
|
||||||
"description": "Service Unavailable"
|
"description": "Service Unavailable"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"security": [
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/public/modules": {
|
"/api/v1/public/modules": {
|
||||||
@@ -26751,6 +26760,100 @@
|
|||||||
"example": true
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.',
|
'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 },
|
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: {
|
PublicEventParticipant: {
|
||||||
|
|||||||
@@ -187,6 +187,16 @@ test("reversible: 'ledger' without revert() is refused at registration", () => {
|
|||||||
assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function')
|
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', () => {
|
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({ perform: undefined })]), /has no perform\(\)/)
|
||||||
assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/)
|
assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/)
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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.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', () => {
|
test('a module registers actions, budgets, leases and option sources', () => {
|
||||||
|
|||||||
174
server/test/eventProgress.test.js
Normal file
174
server/test/eventProgress.test.js
Normal file
@@ -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')
|
||||||
|
})
|
||||||
@@ -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)
|
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 () => {
|
test('a phase the pinned version does not name renders nothing rather than an id', async () => {
|
||||||
store.runs[0].status = 'running'
|
store.runs[0].status = 'running'
|
||||||
store.runs[0].current_phase = 'a-phase-since-renamed'
|
store.runs[0].current_phase = 'a-phase-since-renamed'
|
||||||
|
|||||||
Reference in New Issue
Block a user