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

@@ -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/)

View File

@@ -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', () => {

View 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')
})

View File

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