Files
website/server/test/eventProgress.test.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

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