feat(events): an event action's progress() on a live run's public page (MODULE_API 1.12.0)
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

An event action may declare an optional `progress(envelope)`: how its
step stands while the run is live. Core accepts two shapes and
interprets neither:

- a count: { label, left, of }, rendered "Bandits: 3 of 8 left";
- a percentage: { label, percent }, rendered "The Juggernaut: 62%".

How core asks:

- `events/progress.js` asks each step of a live run whose action has
  one. It passes the step's own held ledger rows and the reader's
  account id, so the module applies its own visibility.
- Each call has a 2 s deadline. A throw, a late answer or any other
  shape is a line not shown, never an error on the page.
- One answer per run and reader is cached for 5 s.
- The public event's live occurrence carries the lines as `progress`.
  The route takes an optional session for the reader.
- The page renders them under the phase label and re-reads them every
  15 s while the event is live.

Core stays game-agnostic: no game's words are in it. Additive, so a
minor bump. Module-uo declares ^1.10.0, registers no action with
progress(), and its actions and pages are unchanged.

From RunicNPC stage 8 (D304, D305).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-06 02:47:00 -05:00
parent f0e7d2aa2a
commit 9ddb59a326
18 changed files with 623 additions and 17 deletions

View File

@@ -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 ''
}

View File

@@ -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'

View File

@@ -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 (
<PublicLayout section="website">
@@ -91,6 +118,16 @@ export default function EventPage() {
the event is never published. */}
{event.current.phase || 'Under way'}
</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 ? (
<>

View File

@@ -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), '')
})