diff --git a/client/src/App.jsx b/client/src/App.jsx index 2b0e598..aac4eee 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -17,6 +17,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx' import Newsletter from './routes/public/Newsletter.jsx' import NewsletterIssue from './routes/public/NewsletterIssue.jsx' import About from './routes/public/About.jsx' +import Events from './routes/public/Events.jsx' +import EventPage from './routes/public/EventPage.jsx' +import EventSeries from './routes/public/EventSeries.jsx' import Status from './routes/public/Status.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' @@ -49,6 +52,11 @@ import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx' import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx' import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx' import EngagementRetention from './routes/admin/views/EngagementRetention.jsx' +import EventsAdmin from './routes/admin/views/EventsAdmin.jsx' +import EventsCalendar from './routes/admin/views/EventsCalendar.jsx' +import EventEditor from './routes/admin/views/EventEditor.jsx' +import EventRun from './routes/admin/views/EventRun.jsx' +import EventActions from './routes/admin/views/EventActions.jsx' import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' @@ -69,6 +77,7 @@ import PlayerNotifications from './routes/player/PlayerNotifications.jsx' import PlayerInbox from './routes/player/PlayerInbox.jsx' import Unsubscribe from './routes/player/Unsubscribe.jsx' import PlayerAppeals from './routes/player/PlayerAppeals.jsx' +import PlayerEvents from './routes/player/PlayerEvents.jsx' export default function App() { return ( @@ -102,6 +111,17 @@ export default function App() { } /> } /> } /> + {/* Events (Phase 14a). `series/:slug` is declared before `:slug` + although it could not be shadowed by it — two segments against + one. It stays above because the ranking surprise this feature + has already shipped once was exactly here: a static segment + outranks a dynamic one whatever the source order, which is what + made `/admin/events/new` unreachable from Phase 6 to Phase 13. + Nothing static shares a segment with `:slug`, so nothing here + repeats it. */} + } /> + } /> + } /> } /> } /> } /> @@ -192,6 +212,27 @@ export default function App() { actions that publish a game-written name is applied per request on the server, from the caller's live role (TEAMS.md 2.9). */} } /> + {/* Events (EVENTS.md §I, Phase 3). Staff-wide, unlike Engagement: + §K makes every read here `staff`, and the moderator's whole + power over this feature is the run console — cancelling a run + that is doing something wrong at 2am. The narrower gates are + applied per action instead: authoring is admin+editor, publish + and start are admin only (§N2), and each button follows the + route it calls. `runs/:runId` is declared before `:id` so the + literal segment is never read as a definition id. */} + } /> + } /> + {/* The switchboard (Phase 6). A literal segment, declared before + `events/:id` the way the router declares `/actions` before + `/:id` — the same collision, on the other side of the wire. */} + } /> + } /> + {/* ONE route, and `new` is a value of `:id` rather than a + path beside it. A static `events/new` outranks the dynamic + segment in React Router whatever the order, so the editor + was handed no `id` at all and asked the API for + `/admin/events/undefined`. */} + } /> {/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the server: every route under /admin/engagement re-gates to `admin` on top of the group's staff gate, because this is the group that @@ -221,6 +262,15 @@ export default function App() { two paths; `lib/notificationPaths.js` is the one mapping. */} } /> } /> + {/* And participation history, for the same reason and by the same + arrangement (Phase 14a): `/player/events/history` is behind + requireAuth alone, so a staff member has one — but + `RequirePlayer` sends them out of `/account`. Declared BEFORE + `events/:id`, though it need not be: a static segment outranks + a dynamic one whatever the order, which is the rule that made + `events/new` unreachable for seven phases. Written in the order + it resolves. */} + } /> {/* Installed modules' admin pages, at /admin//…, already inside RequireAuth + AdminLayout. A module cannot supply its own auth wrapper — only an optional { roles }, which core applies as the @@ -264,6 +314,11 @@ export default function App() { } /> } /> } /> + {/* Participation history (Phase 14a). Under /account rather than + /player because it is role-agnostic self-service: staff are a + superset of players and an admin reading their own attendance + is as ordinary as anyone else doing it. */} + } /> {/* The inbox took `/account/notifications` in engagement Phase 7 and the preferences screen moved under it. Content and settings are different kinds of thing, and the plain word diff --git a/client/src/api/client.js b/client/src/api/client.js index 966adc4..92b4e1c 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -256,6 +256,24 @@ export const api = { // their mail, not signed in. Always resolves 200 whatever the token was. unsubscribeTeam: (token) => req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }), + // ----- Events (EVENTS.md § API surface, Phase 14a) ----- + // + // The anonymous surface. `from`/`to` are optional — the server defaults to now + // through a month out, so the calendar's first render need not compute a window + // before it can ask for anything. + publicEvents: ({ from, to, seriesId } = {}) => { + const qs = new URLSearchParams() + if (from) qs.set('from', from) + if (to) qs.set('to', to) + if (seriesId) qs.set('seriesId', String(seriesId)) + return req(`/public/events${withQs(qs.toString())}`) + }, + // `run` is what an announcement's link carries, so a mail about last Friday's + // occurrence opens last Friday's results rather than next Friday's. + publicEvent: (slug, run = null) => + req(`/public/events/${encodeURIComponent(slug)}${run ? `?run=${encodeURIComponent(run)}` : ''}`), + publicEventSeries: (slug) => req(`/public/events/series/${encodeURIComponent(slug)}`), + wikiTags: () => req('/public/wiki/tags'), wikiPage: (slug) => req(`/public/wiki/${slug}`), // CMS pages (block-based). Published-only for the public; a draft-preview link @@ -472,6 +490,100 @@ export const api = { setEngagementRetention: (body) => req('/admin/engagement/retention', { method: 'PUT', body }), + // Events (docs/website/EVENTS.md, Phase 3). Reads are staff-wide; authoring + // is admin+editor, publish and start are admin ONLY, and the six live + // controls are admin+moderator — the one gate in this feature wider than + // admin, because stopping a run at 2am is incident response and starting + // one is not (§N2). The buttons follow the same split, and the server + // re-checks every one of them. + listEvents: (state) => req(`/admin/events${state ? `?state=${encodeURIComponent(state)}` : ''}`), + getEvent: (id) => req(`/admin/events/${id}`), + createEvent: (body) => req('/admin/events', { method: 'POST', body }), + updateEvent: (id, body) => req(`/admin/events/${id}`, { method: 'PUT', body }), + publishEvent: (id) => req(`/admin/events/${id}/publish`, { method: 'POST' }), + archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }), + listEventVersions: (id) => req(`/admin/events/${id}/versions`), + eventCatalog: () => req('/admin/events/catalog'), + // Phase 7. The values behind a param's `source` — resolved by the module that + // registered the source, on a request of its own rather than inside the + // catalog, because a source can be slow or down and must not take the whole + // editor with it. A refusal comes back 200 with `ok: false`, so this never + // throws for the case the screen is meant to render: the field degrades to + // free text with the reason beside it. + // Phase 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is + // ignored, never refused, by a source that does not declare itself + // searchable — so passing it is always safe and the field decides whether + // it is a typeahead by reading `searchable` off the answer. + eventOptions: (sourceId, q) => { + const qs = q ? `?${new URLSearchParams({ q }).toString()}` : '' + return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`) + }, + // Phase 6. The dry run is admin+editor: it dispatches nothing, and the author + // who wrote the definition is who should be able to price it against the caps + // before asking an admin to publish it. A report with findings comes back 200 + // — the request succeeded, the plan has problems. + verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }), + // Phase 13's live cap meter, and NOT a lighter dry run — it dispatches + // nothing, so it knows nothing a module knows. It takes the spec in the + // body rather than an id because the plan it prices is the one in the + // author's hands, which is unsaved between keystrokes, and it records + // nothing, which is what makes it safe to call on a debounce. + priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }), + // The switchboard, admin only in BOTH directions: reading which actions a + // deployment permits is as much configuration as writing it (§K). One action + // per write rather than the whole board, so an action that appeared between + // the read and the write cannot be overwritten with a default. + eventActions: () => req('/admin/events/actions'), + saveEventAction: (body) => req('/admin/events/actions', { method: 'PUT', body }), + eventSeries: () => req('/admin/events/series'), + // Series writes are admin+editor rather than admin: naming an arc is + // authoring, and §N2's narrow gate is about committing the deployment to a + // run. The delete is a real delete and answers with how many definitions it + // detached — `series_id` is ON DELETE SET NULL, so nothing is destroyed. + createEventSeries: (body) => req('/admin/events/series', { method: 'POST', body }), + updateEventSeries: (id, body) => req(`/admin/events/series/${id}`, { method: 'PUT', body }), + deleteEventSeries: (id) => req(`/admin/events/series/${id}`, { method: 'DELETE' }), + // The calendar. `from`/`to` are UTC instants the caller computes from the + // month it is showing, in the READER's zone — the server never guesses it. + // A `status` or `scope` filter suppresses projections, which is why the + // month view sends neither. + eventCalendar: ({ from, to, status, scope, seriesId } = {}) => { + const qs = new URLSearchParams({ from, to }) + if (status) qs.set('status', status) + if (scope) qs.set('scope', scope) + if (seriesId) qs.set('seriesId', String(seriesId)) + return req(`/admin/events/calendar?${qs.toString()}`) + }, + startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }), + listEventRuns: ({ definitionId, status, limit } = {}) => { + const qs = new URLSearchParams() + if (definitionId) qs.set('definitionId', String(definitionId)) + if (status) qs.set('status', status) + if (limit) qs.set('limit', String(limit)) + const suffix = qs.toString() + return req(`/admin/events/runs${suffix ? `?${suffix}` : ''}`) + }, + getEventRun: (runId) => req(`/admin/events/runs/${runId}`), + getEventRunLog: (runId, limit) => + req(`/admin/events/runs/${runId}/log${limit ? `?limit=${Number(limit)}` : ''}`), + pauseEventRun: (runId, reason) => + req(`/admin/events/runs/${runId}/pause`, { method: 'POST', body: { reason } }), + resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }), + // `cleanup` defaults to true server-side and has to be asked out of: EVENTS.md + // §L makes cancelling WITHOUT cleanup the separate, admin-only, logged action, + // so an absent flag means "give back what this run took". + cancelEventRun: (runId, reason, cleanup = true) => + req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason, cleanup } }), + cleanupEventRun: (runId) => req(`/admin/events/runs/${runId}/cleanup`, { method: 'POST' }), + advanceEventRun: (runId, reason) => + req(`/admin/events/runs/${runId}/advance`, { method: 'POST', body: { reason } }), + confirmEventStep: (runId, stepId, note) => + req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }), + skipEventStep: (runId, stepId, reason) => + req(`/admin/events/runs/${runId}/steps/${stepId}/skip`, { method: 'POST', body: { reason } }), + retryEventStep: (runId, stepId) => + req(`/admin/events/runs/${runId}/steps/${stepId}/retry`, { method: 'POST' }), + // Teams (docs/website/TEAMS.md §2.11). Three of these mean something // different depending on who calls them: for a moderator, unhide and // setTeamDisplayName file a request and the response says `pending: true`. @@ -614,6 +726,18 @@ export const api = { getEligibleAppeals: () => req('/player/appeals/eligible'), submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }), withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }), + + // ----- event participation (Phase 14a) ----- + // + // Self-scoped on the session and nothing else — there is no id to pass. + // `before` is a keyset cursor (the last entry's `id`), not an offset: the + // list gains a row every time the reader attends something. + eventHistory: ({ limit, before } = {}) => { + const qs = new URLSearchParams() + if (limit) qs.set('limit', String(limit)) + if (before) qs.set('before', String(before)) + return req(`/player/events/history${withQs(qs.toString())}`) + }, }, } diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 21eee19..df088e4 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -29,6 +29,7 @@ import { useFeatureGate } from '../modules/features.jsx' export const NAV = [ { label: 'Home', to: '/', end: true }, { label: 'News', to: '/site/news' }, + { label: 'Events', to: '/site/events' }, { label: 'Screenshots', to: '/site/screenshots' }, { label: 'Five on Friday', to: '/site/five-on-friday' }, { label: 'Newsletter', to: '/site/newsletter' }, diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js new file mode 100644 index 0000000..84c4bed --- /dev/null +++ b/client/src/lib/eventAuthoring.js @@ -0,0 +1,817 @@ +// ── What the three Events screens say, and what they let staff press ─────── +// +// EVENTS.md §I. None of this is a boundary. `events/spec.js` on the server +// decides what may be saved, and the six control statements decide what may +// happen to a run — every one of them is a compare-and-set that re-checks the +// status this file only *predicted*. What is here is the part that would be +// wrong silently: a form that drops an authored step, a params box that posts a +// string where the action declared an int, and above all a console that offers a +// button the server is going to refuse. +// +// **The controls are modelled here rather than inline in the console for one +// reason: they can be tested against the server's rules.** A button that 409s is +// not a bug the way a wrong write is, but it is the failure mode an operator +// meets at 2am while the thing they are trying to stop keeps running — so the +// guards are written twice on purpose and the copy is checked. + +// **The condition builder is borrowed, not rebuilt.** §I says the step editor +// reuses "the condition builder, exactly" — and a phase's advance gate is +// literally the engagement grammar, validated on the server by +// `engagement/conditions.js`. Importing the row helpers is what keeps this screen +// from becoming a second opinion about a grammar core owns. +import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js' + +// A run that is over. Verbatim `eventRuns.db`'s TERMINAL. +export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed'] + +export const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status) + +/** A step waiting on a human: `running`, with nothing holding it. */ +export const isParked = (step) => Boolean(step && step.status === 'running' && step.parked) + +/** + * The highest `seq` of a step in this phase that is not still `pending` — the + * furthest the phase has got — or null when none of it has been attempted. + * + * The same rule as the server's `lastStartedSeq`, over the step list the console + * already has, and used only to decide whether to OFFER retry. The near miss is + * worth keeping in view: "the lowest step that is not finished" looks like the + * same thing and is not, because the runner steps OVER a failed step. Under that + * rule a phase that carried on past an `on_failure: skip` failure and then paused + * at a later one would offer retry on the wrong step. + */ +export function lastStartedSeqOf(steps, phase) { + const started = (steps || []) + .filter((s) => s.phase === phase && s.status !== 'pending') + .map((s) => Number(s.seq)) + return started.length ? Math.max(...started) : null +} + +/** + * Which run-level controls to offer. + * + * `pause` is `starting`/`running` only: a `scheduled` occurrence that should not + * happen is cancelled, not paused. `cancel` is everything non-terminal — "this + * is not happening" is a decision made before a run starts as often as during + * one. + * + * **`advance` is offered only when the phase is genuinely waiting on its gate**, + * which is the same test the server makes and is stated here in the same words + * on purpose: this decides what is *offered*, the server decides what is + * *allowed*, and a button that is present and always refused is the "control + * that answers 409 and does nothing" this feature has refused twice. The gate + * must be open-and-unsatisfied AND no step of the phase may still be pending or + * running — a phase held by a step is held by the step, and skip is its control. + */ +export function runControlsFor(run, gates = [], steps = []) { + if (!run) return { pause: false, resume: false, cancel: false, advance: false } + const terminal = isTerminalRun(run.status) + const gate = (gates || []).find((g) => g.phase === run.currentPhase) + const stepOpen = (steps || []).some( + (s) => s.phase === run.currentPhase && ['pending', 'running'].includes(s.status), + ) + return { + pause: ['starting', 'running'].includes(run.status), + resume: run.status === 'paused', + cancel: !terminal, + advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen, + } +} + +/** + * Which step-level controls to offer, for one step of one run. + * + * `retry` carries the guard worth restating: only while the run is PAUSED, only + * on a `failed` step of the phase the run is currently in, and only when that + * step is the furthest one the phase has reached. A failed step under an + * `on_failure` of `skip` is one the run has already moved past, and re-queueing + * it would put a pending row behind the runner's cursor, where it would sit for + * ever. + */ +export function stepControlsFor(run, step, steps) { + const none = { confirm: false, skip: false, retry: false } + if (!run || !step) return none + if (isTerminalRun(run.status)) return none + + const parked = isParked(step) + const furthest = step.phase === run.currentPhase ? lastStartedSeqOf(steps, step.phase) : null + + return { + confirm: parked, + skip: parked || step.status === 'pending', + retry: + run.status === 'paused' && + step.status === 'failed' && + step.phase === run.currentPhase && + furthest !== null && + Number(furthest) === Number(step.seq), + } +} + +// ── The definition form ──────────────────────────────────────────────────── + +export const BLANK_PHASE_KEY = 'phase' + +const nextPhaseKey = (phases) => { + const used = new Set((phases || []).map((p) => p.key)) + for (let n = 1; n < 100; n++) { + const key = n === 1 ? BLANK_PHASE_KEY : `${BLANK_PHASE_KEY}-${n}` + if (!used.has(key)) return key + } + return `${BLANK_PHASE_KEY}-${Date.now()}` +} + +/** + * A new step, with its params PREFILLED from the action's declared examples. + * + * Every param carries a required `example` — that requirement is the reason this + * works — so a fresh `core.announce` step arrives with the right keys and + * plausible values rather than empty. Phase 13 turned the box into a form and + * this stayed exactly as it was: a form whose fields start at the declared + * example is a step an author edits rather than one they compose. + */ +export function blankStep(action) { + const params = {} + for (const p of action?.params || []) { + if (p.required || p.example !== undefined) params[p.name] = p.example + } + return { + actionId: action?.id || '', + label: action?.label || '', + onFailure: '', + paramsText: JSON.stringify(params, null, 2), + } +} + +export function blankPhase(phases) { + return { key: nextPhaseKey(phases), label: 'New phase', steps: [], advance: blankAdvance() } +} + +/** + * The advance gate as the FORM holds it (Phase 5) — three fields that are + * always present and mostly empty, rather than a discriminated union the form + * has to rebuild every time the dropdown moves. + * + * `kind: ''` is "no condition", which is what nearly every phase is and what + * every phase was before this. The form keeps a half-typed `on` gate's trigger + * while the author looks at `after`, because a dropdown that discards what was + * typed under the other option is one an operator learns to be afraid of. + */ +export function blankAdvance() { + return { kind: '', after: '30m', on: '', count: 1, ...blankWhere() } +} + +/** + * The `where` predicate as the BUILDER holds it (Phase 13). + * + * `whereText` survives beside the rows and is not vestigial: it is what a + * predicate the builder cannot render is shown as, and what is posted for one. + * See `whereFormFrom`. + */ +export function blankWhere() { + return { whereOp: 'and', whereRows: [], whereEditable: true, whereText: '' } +} + +export const ADVANCE_KINDS = [ + { value: '', label: 'When its steps are done' }, + { value: 'after', label: 'After a fixed delay' }, + { value: 'on', label: 'When something happens in the game' }, +] + +/** The stored gate, as the form's fields. */ +export function advanceFormFrom(advance) { + const blank = blankAdvance() + if (!advance) return blank + if (advance.after !== undefined) return { ...blank, kind: 'after', after: advance.after } + return { + ...blank, + kind: 'on', + on: advance.on || '', + count: advance.count ?? 1, + ...whereFormFrom(advance.where), + } +} + +/** + * A stored `where` tree → the builder's flat rows (Phase 13). + * + * **This is `conditionRowsFrom` and it is deliberately the same function**, not a + * second one shaped like it. The grammar behind a phase gate is the engagement + * condition grammar — the server validates it with `engagement/conditions.js` + * and renders the diagnosis panel's sentence with the same labels — so an editor + * here that re-decided what a tree looks like would be the second implementation + * §I refuses on the read side for exactly this reason. + * + * A tree the flat editor cannot hold (`A and (B or C)`) comes back + * `whereEditable: false` and is SHOWN as its JSON rather than silently + * flattened: `A and B and C` fires on different events, and an author would have + * no way to know the save had done it to them. + */ +export function whereFormFrom(where) { + const blank = blankWhere() + if (!where) return blank + const rows = conditionRowsFrom(where) + return { + whereOp: rows.op, + whereRows: rows.rows, + whereEditable: rows.editable, + whereText: JSON.stringify(where, null, 2), + } +} + +/** The editor's working state, from what `GET /admin/events/:id` returned. */ +export function formFromDefinition(event) { + const spec = event?.spec || {} + return { + title: event?.title || '', + summary: event?.summary || '', + body: event?.body || '', + imageUrl: event?.imageUrl || '', + seriesId: event?.seriesId ? String(event.seriesId) : '', + seriesOrder: event?.seriesOrder ?? 0, + concurrencyKey: event?.concurrencyKey || '', + graceSeconds: event?.graceSeconds ?? 900, + timezone: event?.timezone || 'UTC', + // Whether the public calendar announces it (Phase 14a). `?? true` rather + // than `|| true`: a definition an operator has deliberately unlisted sends + // `false`, and `||` would quietly re-list it on the next save. + listed: event?.listed ?? true, + // Whether the public calendar announces it (Phase 14a). `?? true` rather + // than `|| true`: a definition an operator has deliberately unlisted sends + // `false`, and `||` would quietly re-list it on the next save. + listed: event?.listed ?? true, + ...scheduleFormFrom(spec.schedule), + phases: (spec.phases || []).map((p) => ({ + key: p.key || '', + label: p.label || '', + advance: advanceFormFrom(p.advance), + steps: (p.steps || []).map((s) => ({ + actionId: s.actionId || '', + label: s.label || '', + onFailure: s.onFailure || '', + dormant: Boolean(s.dormant), + actionVersion: s.actionVersion, + paramsText: JSON.stringify(s.params || {}, null, 2), + })), + })), + } +} + +/** + * One phase's advance gate, as the spec shape — or null when it has none. + * + * **Whether the predicate is VALID is still the server's answer.** The builder + * coerces each literal to the type the trigger DECLARED — which is not a second + * validator but the thing that makes the first one's error useful: every value + * in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int` + * variable is refused by `engagement/conditions.js`, rightly, at which point the + * author is reading an error about JSON rather than about what they typed. + * + * A predicate the builder could not render round-trips through `whereText` + * unchanged. That is the point of keeping the text: the alternative to posting it + * back verbatim is dropping an author's tree because this screen could not draw + * it. + */ +export function advancePayload(advance, where, errors, variables = []) { + if (!advance || !advance.kind) return null + if (advance.kind === 'after') return { after: advance.after } + + const out = { on: advance.on, count: Number(advance.count) || 1 } + if (advance.whereEditable === false) { + const text = String(advance.whereText || '').trim() + if (text) { + try { + out.where = JSON.parse(text) + } catch (err) { + errors.push(`${where}, advance condition: ${err.message}`) + } + } + return out + } + + const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables) + if (built) out.where = built + return out +} + +/** + * The form, as a request body — or the list of everything wrong with it. + * + * Only the JSON parse is checked here, and only because a params box whose text + * is not JSON cannot be turned into a request at all. **Everything else is left + * to the server**: unknown params, wrong types, missing required ones, bad phase + * keys and duplicate keys all come back from `POST`/`PUT` as a list, and + * re-deciding any of them here would be a second validator drifting from the one + * that matters. + * + * `onFailure` is omitted when the author has not chosen one, so the server + * applies the action's risk-class default rather than being told a value the + * form invented. + */ +export function payloadFromForm(form, { triggersById = new Map() } = {}) { + const errors = [] + const phases = (form.phases || []).map((phase, pi) => { + const where = advancePayload( + phase.advance, + `Phase ${pi + 1} "${phase.label || phase.key}"`, + errors, + // The declared types the builder coerces against. A trigger nothing + // registers has none, and every literal then stays the string it was typed + // as — which is right: the gate is dormant, the server carries its `where` + // through unvalidated, and inventing types for it here would edit a + // predicate nobody can currently check. + triggersById.get(phase.advance?.on)?.variables || [], + ) + return { + key: phase.key, + label: phase.label, + // Omitted rather than sent as null when there is no gate, which is what + // `events/spec.js` stores for the same reason: a spec full of + // `"advance": null` makes the first phase to gain one look like an edit to + // every phase in the version diff. + ...(where ? { advance: where } : {}), + steps: (phase.steps || []).map((step, si) => { + const out = { actionId: step.actionId } + if (step.label) out.label = step.label + if (step.onFailure) out.onFailure = step.onFailure + const parsed = parseParams(step.paramsText) + if (parsed.error) { + errors.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${parsed.error}`) + } else { + out.params = parsed.params + } + return out + }), + } + }) + + if (errors.length) return { ok: false, errors } + + return { + ok: true, + payload: { + title: form.title, + summary: form.summary || null, + body: form.body || null, + imageUrl: form.imageUrl || null, + seriesId: form.seriesId ? Number(form.seriesId) : null, + seriesOrder: Number(form.seriesOrder) || 0, + concurrencyKey: form.concurrencyKey || null, + graceSeconds: Number(form.graceSeconds), + timezone: form.timezone, + listed: Boolean(form.listed), + listed: Boolean(form.listed), + spec: { schedule: scheduleFromForm(form), phases }, + }, + } +} + + +// ── The schedule (Phase 4) ───────────────────────────────────────────────── +// +// The four closed shapes of §E, mirrored so the form can render one and the +// preview can describe it. `events/spec.js` and `events/recurrence.js` remain +// the deciders — this is what makes the form a form rather than a text box, and +// it is the whole reason the schedule is not a cron string: a closed set has a +// dropdown, and an operator can proofread a dropdown. + +export const WEEKDAYS = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +] + +export const SCHEDULE_KINDS = [ + { value: 'manual', label: 'Started by hand' }, + { value: 'once', label: 'Once, at a set time' }, + { value: 'weekly', label: 'Weekly, on chosen days' }, + { value: 'monthly', label: 'Monthly, on the nth weekday' }, +] + +// 1..4 and "last". There is no fifth: every month has a first through fourth of +// every weekday, and "last" is what a month with five Fridays makes different +// from "fourth" (org lead, 2026-09-02). +export const MONTHLY_NTHS = [ + { value: 1, label: 'First' }, + { value: 2, label: 'Second' }, + { value: 3, label: 'Third' }, + { value: 4, label: 'Fourth' }, + { value: -1, label: 'Last' }, +] + +const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1) + +/** + * A schedule in words, in the event's own zone. + * + * The server says the same thing in `events/recurrence.js#describe`, and the two + * are allowed to differ on wording but not on meaning — this one is what an + * author reads while they are still typing, before anything has been saved. + */ +export function describeSchedule(schedule, timezone = 'UTC') { + if (!schedule || typeof schedule !== 'object') return 'No schedule' + const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth)) + switch (schedule.kind) { + case 'manual': + return 'Started by hand — nothing happens until an admin presses Start' + case 'once': { + if (!schedule.at) return 'Once — no date chosen yet' + return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})` + } + case 'weekly': { + const days = (schedule.days || []).map(capitalise) + if (!days.length || !schedule.time) return 'Weekly — choose days and a time' + const list = + days.length === 1 + ? days[0] + : `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}` + return `Every ${list} at ${schedule.time} (${timezone})` + } + case 'monthly': { + if (!nth || !schedule.weekday || !schedule.time) { + return 'Monthly — choose a week, a weekday and a time' + } + return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})` + } + default: + return 'No schedule' + } +} + +/** + * The schedule half of the editor's working state. + * + * Every shape's fields are kept side by side rather than cleared when the kind + * changes, so an author who clicks Weekly, then Monthly, then back has not lost + * the days they picked. `scheduleFromForm` reads only the fields the chosen kind + * uses, which is what keeps the request body a clean single shape. + */ +export function scheduleFormFrom(schedule) { + const s = schedule || {} + return { + scheduleKind: s.kind || 'manual', + scheduleAt: s.kind === 'once' ? s.at || '' : '', + scheduleDays: s.kind === 'weekly' ? s.days || [] : [], + scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1', + scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday', + scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00', + } +} + +/** The schedule the form describes, as the spec object the server expects. */ +export function scheduleFromForm(form) { + switch (form.scheduleKind) { + case 'once': + return { kind: 'once', at: form.scheduleAt } + case 'weekly': + return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime } + case 'monthly': + return { + kind: 'monthly', + nth: Number(form.scheduleNth), + weekday: form.scheduleWeekday, + time: form.scheduleTime, + } + default: + return { kind: 'manual' } + } +} + +/** + * What a calendar entry is, and therefore what may be done with it. + * + * A `run` is a row: it has a console and somebody can cancel it. A `projected` + * entry is arithmetic the runner has not reached yet — there is nothing to open + * and nothing to stop, and an operator who treats one as a booking has been + * misled by the UI rather than by the server. + */ +export const isProjected = (entry) => entry?.kind === 'projected' + +/** An empty box is `{}`, not a parse error — a step may legitimately take none. */ +export function parseParams(text) { + const raw = (text || '').trim() + if (!raw) return { params: {} } + let value + try { + value = JSON.parse(raw) + } catch (err) { + return { error: `the params are not valid JSON (${err.message})` } + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { error: 'the params must be a JSON object' } + } + return { params: value } +} + +// ── Step params, as a form (Phase 13) ───────────────────────────── +// +// §I: the step editor is *"the condition builder, exactly — core serves a +// catalog, the module declared the schema, core renders a form it does not +// understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for +// this, and everything the form needs was already in the catalog: a param's +// name, type, whether it is required, its description, its example, and the +// option source behind it. +// +// **The JSON stays as the storage and as the escape hatch, and both halves of +// that matter.** As storage, because `payloadFromForm` already builds a request +// out of it and a second representation would be two things to keep in step. As +// an escape hatch, because a form can only render what the declaration +// describes — and a step may legitimately hold something it does not. +// +// The rule for when the form gives way is the CONDITION BUILDER'S rule, which is +// the reason this reads as a port of it rather than as a new idea: a value the +// editor cannot round-trip is SHOWN rather than silently rewritten. Flattening +// `A and (B or C)` there and dropping an undeclared param here are the same +// mistake — a save that looks clean and means something else. + +/** The two ways a step's params are edited. */ +export const PARAM_FORM = 'form' +export const PARAM_JSON = 'json' + +/** + * Can this step's params be rendered as a form without losing anything? + * + * `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold. + * Three things make one, and none of them is an error — each is a step that has + * to be edited as JSON: + * + * • **the action is dormant.** There is no declaration, so there are no fields. + * A form here would render nothing and look like a step with no params. + * • **a param the action does not declare.** The save refuses it by name, which + * is what the author needs to see — and a form that dropped it would post a + * step that saves cleanly having deleted something they typed. + * • **a value no single control can hold** — an object or an array against a + * scalar declaration. + */ +export function paramsRenderable(action, params) { + if (!action) return { ok: false, reason: 'the module that registered this action is not installed' } + const declared = new Map((action.params || []).map((p) => [p.name, p])) + for (const [name, value] of Object.entries(params || {})) { + if (!declared.has(name)) { + return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` } + } + if (value !== null && typeof value === 'object') { + return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` } + } + } + return { ok: true } +} + +/** + * Which mode should this step open in? + * + * The author's own choice wins whenever the form COULD render the step — an + * author who switched to JSON stays in JSON. What they cannot do is stay in a + * form that would lose something, so an unrenderable step is forced to JSON + * whatever the choice was, and the reason is returned so the screen can say it. + */ +export function paramsMode(step, action) { + const parsed = parseParams(step?.paramsText) + if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error } + const renderable = paramsRenderable(action, parsed.params) + if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason } + return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null } +} + +/** One declared param's current value, as the control holds it. */ +export function paramValue(step, name) { + const parsed = parseParams(step?.paramsText) + if (parsed.error) return undefined + return parsed.params[name] +} + +/** + * Write one param, and give back the whole box. + * + * **An empty field REMOVES the key rather than posting an empty string**, and + * that is the server's own reading rather than a convenience: `checkParams` + * treats `undefined`, `null` and `''` alike — absent — so a required param left + * blank comes back as *"is required"*, which is the error the author needs, + * instead of as a type complaint about `""`. + * + * **A value that does not parse is passed through as typed.** `coerceLiteral` is + * the engagement builder's, unchanged, and its rule is the one that matters + * here too: half of `-` is not a number, and turning it into `NaN` or `0` while + * somebody is still typing would either post a value they never wrote or make + * the field impossible to type a negative into. The server's type check then + * names the param. + * + * Re-serialising the whole object rather than splicing text, for `pickParam`'s + * reason: a string edit that produced valid-looking JSON with a duplicate key + * would be a value the editor and the server read differently. + */ +export function setParam(step, name, raw, type) { + const parsed = parseParams(step?.paramsText) + if (parsed.error) return step?.paramsText || '{}' + const next = { ...parsed.params } + if (raw === '' || raw === undefined || raw === null) delete next[name] + else next[name] = coerceLiteral(type, raw) + return JSON.stringify(next, null, 2) +} + +/** + * A stored `datetime` as a `datetime-local` input wants it, and back. + * + * The server normalises a datetime param to an ISO string (`conditions.js` + * `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The + * slice is the whole conversion in one direction; in the other the input's own + * text is a moment `new Date()` parses, so it is posted as typed and the server + * does the normalising — one implementation of what a datetime is, and it is + * not this one. + */ +export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '') + +/** + * Everything the meter needs out of the form, and nothing else. + * + * The price route takes a spec, not a definition: no title, no schedule, no + * series. Sending the whole payload would put a document in front of a route + * that reads two fields of it — and would fail the moment the rest of the form + * is mid-edit, which is exactly when the meter is being read. + * + * A step whose params do not parse is sent with none rather than dropped, so a + * half-typed JSON box costs its own step's draw and not the phase's. + */ +export function priceBodyFrom(form) { + return { + phases: (form?.phases || []).map((phase) => ({ + key: phase.key || null, + steps: (phase.steps || []).map((step) => ({ + actionId: step.actionId || '', + params: parseParams(step.paramsText).params || {}, + })), + })), + } +} + +/** + * Is this plan worth pricing at all? + * + * A meter that fires on an empty form asks the server what nothing costs, on + * every keystroke of the title field. One step with an action chosen is the + * threshold, because that is the first moment there is an answer. + */ +export const worthPricing = (form) => + (form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId)) + +// ── Rendering what happened ──────────────────────────────────────────────── + +const STATUS_WORDS = { + scheduled: 'Scheduled', + starting: 'Starting', + running: 'Running', + paused: 'Paused', + ending: 'Winding down', + completed: 'Completed', + cancelled: 'Cancelled', + failed: 'Failed', + missed: 'Missed', +} + +export const runStatusWord = (status) => STATUS_WORDS[status] || status || 'unknown' + +const KIND_WORDS = { + 'run.created': 'Occurrence created', + 'run.status': 'Run status', + 'run.health': 'Health', + 'run.blocked': 'Held off', + 'phase.entered': 'Phase entered', + 'phase.completed': 'Phase completed', + 'step.status': 'Step', + 'step.retry': 'Step retried', + 'step.parked': 'Waiting on a human', + 'phase.gate': 'Advance condition set', + 'condition.evaluated': 'Condition evaluated', + 'phase.advanced': 'Phase advanced', + // Phase 6. "Refused" reads differently from "Step" on purpose: an operator + // scanning a stopped run needs to see that nothing is broken. + 'step.refused': 'Refused', + 'run.budget': 'Caps', + 'version.verified': 'Dry run passed', + // Phase 15. "Reported" rather than "Detail": the line is the module talking + // about its own verb, and every other word here names something core did. + 'step.detail': 'Step reported', + note: 'Note', +} + +// How deep and how long a module's own `detail` value is allowed to render. +// The dispatcher already caps the whole object at 4KB, so this is about a line +// staying a line — an operator scanning a run's log should not have one row +// wrap eight times because a module answered with an array of forty names. +const DETAIL_LIST_SHOWN = 5 +const DETAIL_TEXT_MAX = 80 + +/** + * One value out of a module's `detail`, as text. + * + * **Core does not interpret these keys and neither does this.** A module wrote + * the object; the console shows it. That is the whole reason the renderer is + * generic rather than a switch — a switch would be core learning a module's + * vocabulary, which is the thing the module system exists to prevent. + */ +function detailValue(value) { + if (value === null || value === undefined) return '—' + if (Array.isArray(value)) { + const shown = value.slice(0, DETAIL_LIST_SHOWN).map(detailValue).join(', ') + return value.length > DETAIL_LIST_SHOWN + ? `${shown} and ${value.length - DETAIL_LIST_SHOWN} more` + : shown + } + if (typeof value === 'object') { + // A nested object is rendered by its keys rather than as JSON: an operator + // reading a log wants "granted: 8, missed: 4", not a brace. + return Object.entries(value) + .map(([k, v]) => `${k} ${detailValue(v)}`) + .join(', ') + } + const text = String(value) + return text.length > DETAIL_TEXT_MAX ? `${text.slice(0, DETAIL_TEXT_MAX - 1)}…` : text +} + +export const logKindWord = (kind) => KIND_WORDS[kind] || kind + +/** + * One log line as a sentence. + * + * The `detail` of a human control carries `control` and `by`, which is what + * separates "the runner paused this because a world write failed" from "somebody + * pressed pause" — the two are the same transition and the console has to be + * able to tell them apart at a glance. + */ +export function describeLogLine(line) { + const d = line?.detail || {} + const by = d.by ? ' by staff' : '' + switch (line?.kind) { + case 'run.status': + return d.control + ? `${runStatusWord(d.to)}${by} — ${d.control}${d.reason ? `: ${d.reason}` : ''}` + : `${d.from ? `${runStatusWord(d.from)} → ` : ''}${runStatusWord(d.to)}${d.because ? ` (${d.because})` : ''}` + case 'run.health': + return `Health is now ${d.to}${d.because ? ` (${d.because})` : ''}` + case 'run.blocked': + return `Held: run ${d.heldBy} has the concurrency key "${d.concurrencyKey}"` + case 'phase.entered': + return `Entered ${line.phase} (${d.steps ?? '?'} steps)` + case 'phase.completed': + return `${line.phase} finished` + case 'step.parked': + return `${d.action} is waiting on a human` + case 'step.retry': + return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}` + case 'step.status': + return d.control + ? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}` + : `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}` + case 'run.created': + return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}` + case 'phase.gate': + return d.kind === 'after' + ? `${line.phase} advances ${d.after} after it started` + : `${line.phase} advances on ${d.needed} × ${d.trigger}${d.where ? ` where ${d.where}` : ''}` + // Both outcomes are logged, and the near miss is the useful one: it is the + // difference between "the boss did spawn, in the wrong region" and "no boss + // has spawned", which look identical on every other line of this log. + case 'condition.evaluated': + return `${d.trigger} ${d.matched ? 'counted' : 'did not count'} — ${d.seen} of ${d.needed}${ + d.satisfied ? ', condition met' : '' + }` + case 'phase.advanced': + return d.because === 'forced' + ? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}` + : `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s` + // Phase 6. `step.refused` is its own kind rather than a `step.status` for a + // reason an operator feels at 2am: a refusal is not a failure, and the line + // has to say which deployment rule stopped it -- the answer to "not enabled" + // is a switch, and the answer to "over the cap" is a number. + case 'step.refused': + return `${d.action} refused: ${d.error}` + case 'run.budget': + return (d.dimensions || []) + .map((x) => `${x.dimension} capped at ${x.cap === null ? 'nothing' : x.cap}${x.from ? ` (${x.from})` : ''}`) + .join(', ') || 'no caps apply to this run' + case 'version.verified': + return `Version ${d.version} passed its dry run — scheduled occurrences may start` + // Phase 15. The one line whose body core did not compose: a module may answer + // a successful step with a `detail` object, and this renders whatever keys it + // put there. `action` is core's own and is pulled out to lead the sentence; + // everything after it is the module's. + // + // **Without this case the row would render as the literal string + // "step.detail"**, because the default below is a kind word and not a + // sentence — which would be the reporting channel existing and showing + // nothing, the exact failure it was built to fix. + case 'step.detail': { + const { action, ...rest } = d + const body = Object.entries(rest) + .map(([key, value]) => `${key}: ${detailValue(value)}`) + .join(', ') + return body ? `${action || 'A step'} — ${body}` : `${action || 'A step'} reported nothing` + } + default: + return logKindWord(line?.kind) + } +} diff --git a/client/src/lib/eventCalendar.js b/client/src/lib/eventCalendar.js new file mode 100644 index 0000000..fafff21 --- /dev/null +++ b/client/src/lib/eventCalendar.js @@ -0,0 +1,99 @@ +// Rendering an event's instant, shared by the public event screens. +// +// **The split these two functions make is EVENTS.md §I's, and it is the one +// thing about event times that is easy to get wrong.** The server returns UTC +// instants and never guesses the reader's zone. The client places them: +// +// • the DAY an entry is filed under is the reader's own — "what is on this +// month" is a question about the month the person reading is living in; +// • the TIME beside it is always the EVENT's zone, carried on the entry — +// because every listing this feature replaces is written in the shard's +// local zone, and "8pm" means the shard's evening to everyone reading it. +// +// Rendering the time in the reader's zone instead would be defensible and is +// wrong here: a player in Berlin told an American shard's event is at "02:00" +// has been told something true and useless, and told it in a way that makes the +// shard's own announcement look like a mistake. + +/** The event's own wall clock, with the zone named so it misreads as nothing. */ +export function eventTime(instant, timezone) { + try { + const time = new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).format(new Date(instant)) + return `${time} ${shortZone(timezone)}` + } catch { + // An unknown IANA name throws rather than falling back, and an event whose + // timezone column holds a typo must still render. UTC off the instant is the + // honest answer when the zone cannot be honoured. + return `${new Date(instant).toISOString().slice(11, 16)} UTC` + } +} + +/** The zone as a reader recognises it: `America/New_York` → `New York`. */ +function shortZone(timezone) { + if (!timezone) return 'UTC' + const tail = String(timezone).split('/').pop() + return tail.replace(/_/g, ' ') +} + +/** The reader's own day, for the heading an entry is filed under. */ +export function readerDayLabel(instant) { + const d = new Date(instant) + if (Number.isNaN(d.getTime())) return '' + return new Intl.DateTimeFormat(undefined, { + weekday: 'long', + day: 'numeric', + month: 'long', + year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric', + }).format(d) +} + +/** The event's own day and time together, for a page that shows one occurrence. */ +export function eventDateTime(instant, timezone) { + const d = new Date(instant) + if (Number.isNaN(d.getTime())) return '' + try { + return `${new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + weekday: 'long', + day: 'numeric', + month: 'long', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).format(d)} ${shortZone(timezone)}` + } catch { + return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC` + } +} + +// The word beside an entry, for the four public statuses. +// +// **`cancelled` needs the instant, and that is the whole reason this is a +// function rather than a lookup table.** The server publishes `failed` and +// `missed` as `cancelled` too — to a visitor those three are one event, and the +// difference between them is about the deployment — but the three do not share +// one English sentence. "Did not happen" is right for a past occurrence and a +// plain falsehood for a future one, and a run four days out that an operator has +// called off is exactly the common case: the calendar was saying *did not +// happen* about next Friday. +// +// So the tense follows the clock, not the status. A future call-off reads +// **Cancelled**; a past one reads **Did not happen**, which is also the honest +// word for the failed and missed runs folded in with it. +const WORDS = { + live: 'Happening now', + scheduled: 'Scheduled', + completed: 'Finished', +} + +export function statusWord(status, scheduledFor, now = Date.now()) { + if (WORDS[status]) return WORDS[status] + if (status !== 'cancelled') return status + const at = new Date(scheduledFor).getTime() + return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled' +} diff --git a/client/src/lib/notificationPaths.js b/client/src/lib/notificationPaths.js index f571447..48e378d 100644 --- a/client/src/lib/notificationPaths.js +++ b/client/src/lib/notificationPaths.js @@ -19,3 +19,17 @@ export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/a /** The per-channel preferences screen. */ export const notificationSettingsPath = (user) => isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings' + +/** + * This account's own event participation (events Phase 14a). + * + * The third screen to need this mapping, and it needed it for exactly the reason + * the two above did: `GET /player/events/history` is behind `requireAuth` alone, + * self-scoped on `req.user.id` — a staff member has a participation history like + * anyone else, and the group's own header says staff are a superset of players. + * The WEB is what disagrees, because `RequirePlayer` sends them to the login + * page. Found the same way the notifications pair was: signed in as an admin, + * the screen simply redirected. + */ +export const eventHistoryPath = (user) => + isStaff(user) ? '/admin/events/mine' : '/account/events' diff --git a/client/src/modules/version.js b/client/src/modules/version.js index bd6f97b..c2f0754 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,15 @@ // 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.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md +// Phase 7): a module may register event actions, budget dimensions, leases and +// param option sources. All four are server-side registrations and nothing on +// `window.__rg` changed — but what they produce is met on this half, in the step +// editor: an option source is what turns a param from a text box into a dropdown +// of real values, and a budget's label and unit are what the switchboard's cap +// box says beside its number. This file bumps for the reason at the top: the two +// halves state ONE version, and a module declares one `coreApi` range against +// both. // 1.9.0 - a module may ship its own message bodies and rules: // `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase // 11b, decision 7). Nothing on this half changed - a seed is server-side data @@ -65,4 +74,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.9.0' +export const MODULE_API_VERSION = '1.10.0' diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 3010eac..452611a 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -53,6 +53,7 @@ const IconList = () => const IconSpark = () => const IconLog = () => +const IconCalendar = () => // Nav is grouped into collapsible categories. A group with no `title` renders // its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles` @@ -120,6 +121,35 @@ export const NAV = [ { to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] }, ], }, + { + // Its own top-level group rather than a row under Content, and staff-wide + // rather than admin-only. Both follow EVENTS.md §K: every read here is + // `staff`, and the moderator's entire power over this feature is the run + // console — the thing they open when an event is doing something wrong at + // 2am. Hiding it from them would leave the one role that exists for incident + // response unable to see the incident. The narrower gates live on the + // actions: authoring is admin+editor and publish/start are admin only, both + // enforced server-side and mirrored on the buttons. + title: 'Events', + items: [ + { to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] }, + // Phase 4. The same staff gate as the list beside it: a calendar is a read, + // and the arcs it manages are authoring gated on the buttons rather than + // on the row. + { to: '/admin/events/calendar', label: 'Calendar', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] }, + // Phase 6, and the one row in this group that is NOT staff-wide. §K puts + // the switchboard in the same row as the world-changing actions it + // governs: what a deployment permits at all is configuration, not a read, + // and the server gates both the GET and the PUT on `admin`. + { to: '/admin/events/actions', label: 'Actions', icon: IconGear, roles: ['admin'] }, + // Phase 14a, and the one row here that is not about running the + // deployment: it is this staff member's OWN attendance, the same screen + // and the same route a player reads at /account/events. It has no `roles` + // because it needs none — every account has a participation history, and + // the server scopes it to the caller. + { to: '/admin/events/mine', label: 'My participation', icon: IconCalendar }, + ], + }, { title: 'System', items: [ @@ -202,6 +232,11 @@ const TITLES = { '/admin/engagement/suppressions': 'Suppressions', '/admin/engagement/sends': 'Send Log', '/admin/engagement/retention': 'Retention', + '/admin/events': 'Events', + '/admin/events/calendar': 'Event calendar', + '/admin/events/actions': 'Event actions', + '/admin/events/mine': 'My participation', + '/admin/events/new': 'New event', } // An installed module's admin pages are not in TITLES and cannot be — core does @@ -222,6 +257,10 @@ function sectionTitle(pathname) { if (pathname.startsWith('/admin/moderation')) return 'Moderation' if (pathname.startsWith('/admin/users/')) return 'User' if (pathname.startsWith('/admin/engagement')) return 'Engagement' + // /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both + // belong to the same section as far as the page title is concerned. + if (pathname.startsWith('/admin/events/runs/')) return 'Event run' + if (pathname.startsWith('/admin/events/')) return 'Event' return 'Admin' } diff --git a/client/src/routes/admin/views/EventActions.jsx b/client/src/routes/admin/views/EventActions.jsx new file mode 100644 index 0000000..dadfb29 --- /dev/null +++ b/client/src/routes/admin/views/EventActions.jsx @@ -0,0 +1,280 @@ +import { useCallback, useEffect, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { api } from '../../../api/client.js' + +// Admin → Events → Actions — the deployment's switchboard (EVENTS.md §K, Phase 6). +// +// **This screen is the whole of the permission model beyond the role.** A module +// declaring `uo.creature.spawn` is code the operator installed; it is not a +// permission they granted. Enablement is the grant, and the cap is how much of +// it — so this is the one screen in the feature where an operator decides what +// the deployment *can do at all*, rather than what it is going to do tonight. +// +// **Nothing above `notify` and `inspect` arrives enabled.** Installing a module +// must never start doing things, which is the posture a seeded engagement rule +// already takes by arriving `enabled = 0`. The line falls between `inspect` and +// `change` (org lead, 2026-09-03): an `inspect` action reads state and writes +// nothing, so a deployment gains no risk by having it on, and `core.wait` — which +// is `inspect` — arriving off would break every published event that waits. +// +// **A row with no stored setting is not "off".** It is "the default for its risk +// class", computed on the server by the same function the runner asks. The screen +// says which it is looking at, because "an admin turned this on" and "this has +// always been on" are different facts and only one of them is a decision. +// +// **Admin only in both directions**, including the read: §K puts the switchboard +// in the same row as the world-changing actions it governs, and knowing exactly +// what a deployment permits is not a staff-wide read. + +const RISK_WORD = { + notify: 'Tells people something', + inspect: 'Reads the world', + change: 'Changes the world', + irreversible: 'Changes the world irreversibly', +} + +const RISK_COLOR = { + notify: 'var(--muted)', + inspect: 'var(--muted)', + change: '#d9c184', + irreversible: '#d98b84', +} + +const REVERSIBLE_WORD = { + none: 'nothing to undo', + self: 'undoes itself', + ledger: 'undone from the ledger at teardown', + override: 'restores a baseline', +} + +export default function EventActions() { + const [actions, setActions] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(null) + const [problem, setProblem] = useState(null) + const [notice, setNotice] = useState(null) + // Cap edits are held here until they are saved, keyed `actionId:dimension`. + // A cap is a number somebody types digit by digit, and writing on every + // keystroke would put "3" in the database on the way to "30". + const [drafts, setDrafts] = useState({}) + + const load = useCallback(async () => { + const data = await api.admin.eventActions() + setActions(data.actions || []) + }, []) + + useEffect(() => { + let alive = true + ;(async () => { + setLoading(true) + try { + await load() + if (alive) setError(null) + } catch (err) { + if (alive) setError(err.message) + } finally { + if (alive) setLoading(false) + } + })() + return () => { + alive = false + } + }, [load]) + + /** + * Write one action's row. + * + * The whole row goes every time — the switch and every cap — because the route + * takes one action per request and a sparse write would have to decide what an + * omitted cap means. Here it can only mean one thing, so it is sent. + */ + const save = async (action, { enabled = action.enabled, caps } = {}) => { + setBusy(action.id) + setProblem(null) + setNotice(null) + const nextCaps = caps !== undefined ? caps : capsOf(action) + try { + await api.admin.saveEventAction({ actionId: action.id, enabled, caps: nextCaps }) + await load() + setDrafts((d) => { + const next = { ...d } + for (const d of action.dimensions) delete next[`${action.id}:${d.id}`] + return next + }) + setNotice(`Saved ${action.label}.`) + } catch (err) { + setProblem(err.message) + } finally { + setBusy(null) + } + } + + /** The caps this row would save: the drafts on top of what is stored. */ + const capsOf = (action) => { + const out = {} + for (const { id: dimension } of action.dimensions) { + const draft = drafts[`${action.id}:${dimension}`] + const value = draft !== undefined ? draft : action.caps[dimension] + if (value === '' || value === undefined || value === null) continue + out[dimension] = Number(value) + } + return out + } + + const capValue = (action, dimension) => { + const draft = drafts[`${action.id}:${dimension}`] + if (draft !== undefined) return draft + const stored = action.caps[dimension] + return stored === undefined || stored === null ? '' : String(stored) + } + + const dirty = (action) => + action.dimensions.some((d) => drafts[`${action.id}:${d.id}`] !== undefined) + + if (loading) return + if (error) return + + return ( +
+

Event actions

+

+ What this deployment permits an event to do, and how much of it per run. Anything that changes + the world arrives switched off — installing a module declares a verb, it does not grant + permission to use it. Caps are copied into a run when the run is created, so moving a switch + never changes what a run already in flight is allowed. +

+ + {problem && ( +
+ {problem} +
+ )} + {notice && ( +
+ {notice} +
+ )} + + {actions.length === 0 && ( +
+

+ No module registers an event action. Core always declares its own three, so an empty list + here means the registry did not load. +

+
+ )} + + {actions.map((action) => ( +
+
+
+
+ {action.label} + {action.id} +
+ {action.description && ( +

{action.description}

+ )} +

+ {RISK_WORD[action.risk] || action.risk} + {' · '} + {REVERSIBLE_WORD[action.reversible] || action.reversible} + {/* Which of the two facts this is. A default is not a decision, and + an operator auditing their own deployment needs to see the + difference without reading the risk table in their head. */} + {' · '} + {action.configured + ? `set by ${action.updatedBy || 'an administrator'}` + : 'never configured — showing the default for its risk class'} +

+
+ + +
+ + {action.dimensions.length > 0 && ( +
+

+ Per-run caps. Blank is uncapped — the run still counts what it spends, nothing bounds + it. Where another enabled action spends the same thing, the tightest cap is the one a + run gets. +

+
+ {action.dimensions.map((d) => ( + + ))} + +
+
+ )} +
+ ))} +
+ ) +} diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx new file mode 100644 index 0000000..d19eac2 --- /dev/null +++ b/client/src/routes/admin/views/EventEditor.jsx @@ -0,0 +1,1553 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useAuth } from '../../../contexts/AuthContext.jsx' +import { api } from '../../../api/client.js' +import { + formFromDefinition, + payloadFromForm, + blankPhase, + blankAdvance, + blankWhere, + whereFormFrom, + ADVANCE_KINDS, + blankStep, + describeSchedule, + scheduleFromForm, + SCHEDULE_KINDS, + MONTHLY_NTHS, + WEEKDAYS, + PARAM_FORM, + PARAM_JSON, + paramsMode, + paramValue, + setParam, + datetimeInputValue, + priceBodyFrom, + worthPricing, +} from '../../../lib/eventAuthoring.js' +import { operatorsForType } from '../../../lib/engagementRules.js' + +// Admin → Events → the definition editor (EVENTS.md §I, Phase 3). +// +// **A vertical timeline, not a node graph**, and that is a decision about what +// the engine can actually do rather than a matter of taste. The condition +// grammar has no branching — it is `and`/`or`/`not` over comparisons, bounded at +// depth five — so a canvas would promise power this project has never handed an +// operator. Phases in order, each with its steps in order, says exactly what the +// runner does with them. +// +// **Core renders no game word here.** Every label on a step comes from the +// action's own registration — its `label`, its params' names, their descriptions +// and their examples — so an installed module's vocabulary appears without core +// knowing any of it, and `check:modules` already fails core's build on a UO +// identifier. +// +// **The params are a FORM as of Phase 13**, one control per declared param, +// rendered from a schema core does not understand — §I's *"the condition +// builder, exactly"*. The JSON box did not go away: it is the escape hatch, and +// a step opens in it automatically when the form could not hold what the step +// carries. That rule is the condition builder's own, ported rather than +// reinvented — dropping a param the action does not declare and flattening +// `A and (B or C)` are the same mistake, a save that looks clean and means +// something else. +// +// **The meter beside the timeline is not a lighter dry run.** `POST +// /admin/events/price` dispatches nothing, so it knows nothing a module knows — +// whether the landmark exists, whether the shard is up. It answers the half core +// can answer alone, which is what a plan would SPEND, and it can therefore run on +// a debounce while somebody types. The dry run stays the thing that asks the +// modules, and the screen labels them apart. + +/** + * The values behind one param's `source` (§F *Param option sources*, Phase 7). + * + * **A refusal renders as a warning and leaves the field usable**, which is the + * contract rather than a nicety: a source is answered by a module that may be + * talking to a sidecar, and an authoring form a shard outage can make unusable + * would be a worse failure than the typo the dropdown exists to prevent. The + * operator very often knows the value they want to type. + * + * The picker WRITES INTO THE JSON box rather than replacing it, because the box + * is still the field until the schema-driven form arrives — so this is the one + * affordance that can exist today and be right afterwards: the values come from + * the module, and the exact spelling is never typed by hand. When the JSON does + * not parse the picker says so rather than silently doing nothing, because + * "clicked and nothing happened" is the one behaviour a form must never have. + */ +function ParamOptions({ entry, label, disabled, onPick }) { + if (!entry || entry.state === 'loading') { + return
Reading the list…
+ } + if (entry.state === 'failed') { + return ( +
+ {entry.reason} — type the value by hand. +
+ ) + } + if (!entry.options.length) { + return ( +
+ {label} has nothing to offer right now — type the value by hand. +
+ ) + } + + const grouped = entry.options.some((o) => o.group) + const groups = grouped + ? [...new Set(entry.options.map((o) => o.group || 'Other'))] + : [] + + return ( + + ) +} + +/** + * A source too large for a dropdown, as a search box (Phase 13). + * + * **The first source that needed this made it unavoidable.** Phase 12b's spawner + * target is 6,707 spawn points against `MAX_OPTIONS`' bound of 2,000, so a flat + * list drops two thirds of the world and says nothing about which two thirds — + * an author picking from it would be choosing from a truncation they cannot see. + * The sidecar half of that shipped in 12b; this is what asks. + * + * `searchable` on the answer decides which control is drawn, rather than the + * length of the list: inferring it from a truncated answer reads correctly right + * up until a small deployment's list happens to fit, at which point the same + * source is a dropdown on one shard and a search box on another. + * + * A term is sent on a debounce and the answer is dropped if it is not the one + * for the term still in the box — a slow source answering after a faster one + * would otherwise repaint the list under the author's cursor with results for + * something they have finished typing. + */ +function SearchableOptions({ sourceId, label, disabled, onPick }) { + const [term, setTerm] = useState('') + const [state, setState] = useState({ status: 'idle', options: [] }) + const latest = useRef('') + + useEffect(() => { + const wanted = term.trim() + latest.current = wanted + if (!wanted) { + setState({ status: 'idle', options: [] }) + return undefined + } + setState((s) => ({ ...s, status: 'loading' })) + const timer = setTimeout(async () => { + try { + const answer = await api.admin.eventOptions(sourceId, wanted) + if (latest.current !== wanted) return + setState( + answer?.ok + ? { status: 'ok', options: answer.options || [] } + : { status: 'failed', options: [], reason: answer?.reason || 'this list could not be read' }, + ) + } catch (err) { + if (latest.current !== wanted) return + setState({ status: 'failed', options: [], reason: err.message || 'this list could not be read' }) + } + }, 250) + return () => clearTimeout(timer) + }, [term, sourceId]) + + return ( +
+ setTerm(e.target.value)} + /> + {state.status === 'loading' && ( +
Searching…
+ )} + {state.status === 'failed' && ( +
+ {state.reason} — type the value by hand. +
+ )} + {state.status === 'ok' && state.options.length === 0 && ( +
+ Nothing matches “{term}”. +
+ )} + {state.status === 'ok' && state.options.length > 0 && ( +
    + {state.options.map((o) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} + +/** + * One declared param, as the control its type implies (Phase 13). + * + * **Core does not know what any of this means and that is the design.** The + * label is the param's own name, the help is its own description, the placeholder + * is its own example, and the values behind it come from the module that declared + * the source — `check:modules` fails core's build on a UO identifier, so there is + * nowhere for a game word to be written here even by accident. + * + * Two of the controls are worth their own sentence: + * + * • **A boolean is a three-value select, not a checkbox.** A checkbox cannot say + * *"not set"*, and for an OPTIONAL boolean that is a real third state — the + * action's own default. A checkbox would post `false` for every param an author + * never touched. + * • **A source-backed param keeps its free-text field.** The dropdown writes + * into it; it does not replace it. §F: a source is answered by a module that may + * be talking to a sidecar, and an authoring form a shard outage can make + * unusable is a worse failure than the typo the dropdown exists to prevent. + */ +function ParamField({ param, value, sourceEntry, disabled, onChange }) { + const common = { className: 'input', disabled, style: { fontSize: '0.8rem' } } + const asText = value === undefined || value === null ? '' : String(value) + + let control + if (param.type === 'boolean') { + control = ( + + ) + } else if (param.type === 'datetime') { + control = ( + onChange(e.target.value)} + /> + ) + } else if (param.type === 'int' || param.type === 'float') { + control = ( + onChange(e.target.value)} + /> + ) + } else if (param.type === 'url') { + control = ( + onChange(e.target.value)} /> + ) + } else { + control = ( + onChange(e.target.value)} /> + ) + } + + return ( + + ) +} + +/** + * The advance condition, as a builder rather than as JSON (Phase 13). + * + * **This is the engagement condition builder**, and being the same one is the + * point rather than a saving: the grammar is `engagement/conditions.js`, the + * server validates a phase gate with it, and the run console's diagnosis panel + * renders its sentence from the same labels. A second editor here would be a + * second opinion about a grammar core owns — exactly what §I refuses on the read + * side, where the sentence is rendered on the server for the same reason. + * + * It offers the FLAT half of the grammar — one `and`/`or` over a list of + * comparisons — because that is what a dropdown per operator can render + * honestly. A tree it cannot hold opens READ-ONLY with its JSON showing and one + * choice: leave it, or clear it and start again. Flattening `A and (B or C)` + * into `A and B and C` changes which firings release the phase, and an author + * would have no way to know the save had done it. + */ +function WhereBuilder({ advance, trigger, operators, disabled, onChange }) { + const variables = trigger?.variables || [] + const rows = advance.whereRows || [] + + if (advance.whereEditable === false) { + return ( +
+ Only when +
+          {advance.whereText}
+        
+

+ This condition nests, and the builder only holds one and/or over a + flat list. It is kept exactly as authored and posted back unchanged — flattening it would + change which firings release the phase without saying so. +

+ +
+ ) + } + + const setRow = (i, patch) => + onChange({ whereRows: rows.map((r, j) => (j === i ? { ...r, ...patch } : r)) }) + + return ( +
+ Only when + {rows.length === 0 && ( +

+ Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for + firings that match. +

+ )} + {rows.length > 1 && ( + + )} + {rows.map((row, i) => { + const declared = variables.find((v) => v.name === row.variable) + const usable = operatorsForType(operators, declared?.type) + const valueless = row.cmp === 'present' || row.cmp === 'absent' + return ( +
+ + + {!valueless && ( + setRow(i, { value: e.target.value })} /> + )} + +
+ ) + })} + + {!variables.length && advance.on && ( + + This trigger declares no variables, so there is nothing to narrow on. + + )} +

+ A list operator takes comma-separated values. Every literal is read as the type the trigger + declared, and a variable it does not have comes back named from the save. +

+
+ ) +} + +/** + * The live cap meter (§I, Phase 13). + * + * Two facts per dimension — what this plan draws, and what the deployment allows + * — because that is all a cap is. §I says the same thing about the run console's + * meter: *"a meter per dimension rather than a sentence, because unlike a gate a + * cap is two numbers and a name and needs no grammar rendered to be read."* + * + * **It says what it does not know.** A step core could not price makes every + * total below an under-count, and an author reading a number smaller than what + * will happen is worse off than one reading no number at all. So `unpriced` is + * rendered as prominently as the totals, not tucked underneath them. + * + * And it does not claim to be the dry run: the caption says so, because a green + * meter beside a plan whose landmarks do not exist would otherwise read as a + * pass. + */ +function CapMeter({ report, budgets, stale }) { + const labelOf = (id) => budgets.find((b) => b.id === id)?.label || id + const unitOf = (id) => budgets.find((b) => b.id === id)?.unit || '' + if (!report) return null + const nothing = report.cost.length === 0 && report.unpriced.length === 0 + return ( +
+
+

+ What this plan draws + {stale && · recalculating…} +

+ + {report.steps} step{report.steps === 1 ? '' : 's'} + +
+ + {nothing && ( +

+ Nothing in this plan spends a capped resource. +

+ )} + + {report.cost.length > 0 && ( + + + {report.cost.map((c) => ( + + + + + + ))} + +
{labelOf(c.dimension)}{c.total} {unitOf(c.dimension)} + {c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`} +
+ )} + + {report.unpriced.length > 0 && ( +
+

+ These totals are incomplete. +

+
    + {report.unpriced.map((u, i) => ( +
  • + phase {u.phase + 1} · step {u.seq + 1} + {' — '}{u.message} +
  • + ))} +
+
+ )} + +

+ Arithmetic only — nothing was dispatched, so this does not know whether the places and things + these steps name exist. The dry run asks the modules that do. +

+
+ ) +} + +/** + * Starting a run, with the three things the route has always taken (Phase 13). + * + * **Two of them were unreachable from this screen until now**, and one of those + * is not a nicety: `concurrencyKey` is a `{placeholder}` template rendered from + * the RUN's own params, so an event whose key names one could not be started + * correctly from the UI at all — every manual run rendered the same key and the + * second one was refused as an overlap. + * + * **Rehearsal is a real run.** §I: the world changes are real, the announcements + * are ceilinged to `staff`. It is not a dry run and the dialog says so, because + * the two words are near enough to swap in a hurry. + */ +function StartDialog({ onStart, onCancel, busy }) { + const [rehearsal, setRehearsal] = useState(false) + const [scope, setScope] = useState('') + const [paramsText, setParamsText] = useState('{}') + const [problem, setProblem] = useState(null) + + const go = () => { + let params = null + const text = paramsText.trim() + if (text && text !== '{}') { + try { + params = JSON.parse(text) + } catch (err) { + setProblem(`The params are not valid JSON (${err.message})`) + return + } + if (!params || typeof params !== 'object' || Array.isArray(params)) { + setProblem('The params must be a JSON object') + return + } + } + onStart({ rehearsal, scope: scope || undefined, ...(params ? { params } : {}) }) + } + + return ( +
+

Start a run now

+ + + +
+ +