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
175 lines
6.5 KiB
JavaScript
175 lines
6.5 KiB
JavaScript
// ── 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')
|
|
})
|