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
98 lines
4.6 KiB
JavaScript
98 lines
4.6 KiB
JavaScript
// The public event screens' time rendering (EVENTS_PLAN.md Phase 14a).
|
||
//
|
||
// One property matters here and it is EVENTS.md §I's: **the time beside an
|
||
// entry is the EVENT's zone, the day it is filed under is the READER's.** A
|
||
// helper that quietly rendered both in the reader's zone would pass any test
|
||
// that only ever looked at one of them, and would put an American shard's 8pm
|
||
// event at "02:00" for a player in Berlin — true, useless, and looking like the
|
||
// shard's own announcement was wrong.
|
||
|
||
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
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
|
||
// observable at all.
|
||
const INSTANT = '2026-09-12T00:00:00.000Z'
|
||
|
||
test('the time is rendered in the EVENT’s zone, not the reader’s', () => {
|
||
assert.equal(eventTime(INSTANT, 'America/New_York'), '20:00 New York')
|
||
assert.equal(eventTime(INSTANT, 'UTC'), '00:00 UTC')
|
||
assert.equal(eventTime(INSTANT, 'Europe/Berlin'), '02:00 Berlin')
|
||
})
|
||
|
||
test('the zone is named in a form a reader recognises', () => {
|
||
// `America/New_York` is a database identifier, not something to show a player.
|
||
assert.match(eventTime(INSTANT, 'America/Los_Angeles'), /Los Angeles$/)
|
||
})
|
||
|
||
test('an unknown zone falls back to UTC rather than throwing', () => {
|
||
// `Intl` rejects an unknown identifier, and an event whose timezone column
|
||
// holds a typo must still render.
|
||
assert.equal(eventTime(INSTANT, 'Not/AZone'), '00:00 UTC')
|
||
assert.equal(eventDateTime(INSTANT, 'Not/AZone'), '2026-09-12 00:00 UTC')
|
||
})
|
||
|
||
test('a bad instant renders as nothing rather than as "Invalid Date"', () => {
|
||
assert.equal(readerDayLabel('not a date'), '')
|
||
assert.equal(eventDateTime('not a date', 'UTC'), '')
|
||
})
|
||
|
||
test('the day label is the reader’s own day, whatever the event’s zone', () => {
|
||
// Two entries at the same instant in different event zones are filed under one
|
||
// heading, which is what makes a chronological list group correctly.
|
||
assert.equal(readerDayLabel(INSTANT), readerDayLabel(INSTANT))
|
||
const label = readerDayLabel(INSTANT)
|
||
assert.ok(label.length > 0)
|
||
// The instant's UTC date is the 12th and New York's is the 11th; the label
|
||
// must not carry a zone at all, because it is neither of theirs.
|
||
assert.equal(/UTC|New York/.test(label), false)
|
||
})
|
||
|
||
test('eventDateTime carries the day and the zone together', () => {
|
||
const text = eventDateTime(INSTANT, 'America/New_York')
|
||
assert.match(text, /New York$/)
|
||
assert.match(text, /20:00/)
|
||
})
|
||
|
||
// ── The status word ────────────────────────────────────────────────────────
|
||
//
|
||
// Found by the browser walk: the calendar was saying "DID NOT HAPPEN" about a
|
||
// run four days out that an operator had cancelled. The server publishes
|
||
// `failed` and `missed` as `cancelled` too — to a visitor the three are one
|
||
// event — but they do not share one English sentence, so the tense follows the
|
||
// clock rather than the status.
|
||
|
||
const NOW = Date.parse('2026-09-08T12:00:00Z')
|
||
|
||
test('a cancelled occurrence in the future reads "Cancelled"', () => {
|
||
assert.equal(statusWord('cancelled', '2026-09-12T18:00:00Z', NOW), 'Cancelled')
|
||
})
|
||
|
||
test('a cancelled occurrence in the past reads "Did not happen"', () => {
|
||
// Which is also the honest word for the failed and missed runs folded into
|
||
// `cancelled` on the way out.
|
||
assert.equal(statusWord('cancelled', '2026-09-04T18:00:00Z', NOW), 'Did not happen')
|
||
})
|
||
|
||
test('the other three words do not depend on the clock at all', () => {
|
||
for (const at of ['2026-09-04T18:00:00Z', '2026-09-12T18:00:00Z']) {
|
||
assert.equal(statusWord('live', at, NOW), 'Happening now')
|
||
assert.equal(statusWord('scheduled', at, NOW), 'Scheduled')
|
||
assert.equal(statusWord('completed', at, NOW), 'Finished')
|
||
}
|
||
})
|
||
|
||
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), '')
|
||
})
|