diff --git a/client/src/routes/admin/views/EventRun.jsx b/client/src/routes/admin/views/EventRun.jsx index e3ff02e..78398f6 100644 --- a/client/src/routes/admin/views/EventRun.jsx +++ b/client/src/routes/admin/views/EventRun.jsx @@ -93,6 +93,12 @@ const STEP_COLOR = { const when = (v) => (v ? new Date(v).toLocaleString() : '—') const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '') +// How many participants the console renders before it stops and counts the rest. +// A run's participants are people and a busy event has hundreds; this panel is a +// check that the collection worked and that the ranking looks right, not the +// results page — that is Phase 14's, and it is public. +const PARTICIPANTS_SHOWN = 50 + /** * Seconds as an operator reads them — the same vocabulary the spec authors a * gate in, so "28 min" on this screen and `after: '30m'` in the editor are @@ -183,6 +189,9 @@ export default function EventRun() { // What this run created or borrowed, and what became of each (Phase 8). const [resources, setResources] = useState([]) const [unresolved, setUnresolved] = useState(0) + // Who took part, best first (Phase 10). Present whether or not the results + // have been published; `run.resultsPublishedAt` is what says which. + const [participants, setParticipants] = useState([]) const [lines, setLines] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -205,6 +214,7 @@ export default function EventRun() { setBudget(detail.budget || []) setResources(detail.resources || []) setUnresolved(detail.unresolvedResources || 0) + setParticipants(detail.participants || []) setLines(log.log || []) }, [runId]) @@ -522,6 +532,63 @@ export default function EventRun() { )} + {/* ── Who took part (Phase 10) ── + Shown whenever a module has reported anybody, published or not — and the + difference between the two is the whole point of the line under the + heading. A run whose participants are collected and unranked is a real + state, not an error: an author has not placed a `core.results.publish` + step, or has not run it yet. Saying "not published yet" is what stops + somebody reading this table as the final standings. */} + {participants.length > 0 && ( +
+

+ Who took part +

+

+ {run.resultsPublishedAt ? ( + <>Results published {clock(run.resultsPublishedAt)}. Ranked best first. + ) : ( + <> + {participants.length} recorded, and the results have not been published — nothing + outside this page shows them, and nobody has a rank yet. Publishing is a{' '} + core.results.publish step in the event + itself. + + )} +

+ + + {participants.slice(0, PARTICIPANTS_SHOWN).map((p) => ( + + + + {/* A participant with no `userId` is not a defect: it is + somebody who turned up without a linked website account, + and the module is the only thing that could have known + otherwise. Saying so beats a blank cell. */} + + + + + ))} + +
+ {p.rank ?? ''} + + {p.memberKey} + + {p.userId ? `account ${p.userId}` : 'no linked account'} + {p.score} + {clock(p.joinedAt)} +
+ {participants.length > PARTICIPANTS_SHOWN && ( +

+ and {participants.length - PARTICIPANTS_SHOWN} more. +

+ )} +
+ )} + {/* ── Waiting on a person ── */} {parked.length > 0 && (
diff --git a/server/db/schema.sql b/server/db/schema.sql index 76744bb..805f4bb 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -2597,3 +2597,83 @@ CREATE TABLE IF NOT EXISTS event_run_resources ( -- start is watching, and that human is the review the gate exists to require. ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL; ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL; + +-- ── Integrations: participants, results and the run's announcements +-- (EVENTS.md §D/§J — Phase 10) ───────────────────────────────────────────── + +-- Who took part, and how well. The eleventh and last of §D's core tables. +-- +-- **Core writes this table and never sources it.** A `member_key` is +-- module-opaque, exactly like a resource's `ref`: core cannot map "Darrow of +-- Britain" onto a user row and must not try, because the mapping is one game's +-- (`shard_links`, for module-uo) and would be compiled into core the moment it +-- guessed. A module that knows both halves supplies both — `memberKey` always, +-- `userId` when its own link table has one — and core stores what it is told. +-- +-- `SET NULL` rather than `CASCADE`, matching `engagement_sends`: a record of what +-- happened at an event has to survive the deletion of an account that attended +-- it, or the results of last year's invasion silently rewrite themselves. +-- +-- **`rank` is NULL until results are published** and is computed then, by +-- `core.results.publish`, over `score DESC`. It is a stored column rather than a +-- window function in the read because a published result is a fact about a +-- moment: a participant added afterwards (a late correction, a module's second +-- collect step) must not silently renumber a table people have already read. +CREATE TABLE IF NOT EXISTS event_run_participants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + run_id BIGINT NOT NULL, + -- Module-opaque and NOT NULL: it is the identity the module knows, and the + -- half of the unique key that makes a repeated collect idempotent. A run whose + -- module cannot name its participants has no rows here at all. + member_key VARCHAR(190) NOT NULL, + user_id INT NULL, + -- Signed, because a game may score downward as readily as upward, and DECIMAL + -- rather than a float so two equal scores compare equal and a rank is stable. + score DECIMAL(18,4) NOT NULL DEFAULT 0, + rank_at INT NULL, + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Module-opaque. Whatever the module wants results to be able to display + -- beside a name -- a class, a city, a kill count -- with no core vocabulary in + -- it and nothing core ever reads. + meta JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evpart_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, + CONSTRAINT fk_evpart_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + -- One row per participant per run. What makes a module reporting the same set + -- twice -- a retried collect step, a second attempt after a timeout -- an + -- upsert rather than a duplicated leaderboard. + UNIQUE KEY uq_evpart_member (run_id, member_key), + -- The results table: one run, best first. + INDEX idx_evpart_score (run_id, score), + -- Profile history (`GET /player/events/history`, Phase 14), newest first. + INDEX idx_evpart_user (user_id, joined_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- When the results table was published, and by which run of the publish action. +-- +-- A stamp rather than a status: a run either has published results or it has +-- not, and the two questions a surface asks -- "may I show this table" and "when +-- was it settled" -- are the same column. `core.results.publish` is idempotent +-- against it (a re-run re-ranks and re-stamps), which is what makes it safe as an +-- ordinary retried step. +ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NULL; + +-- The run an announce job belongs to, when it belongs to one. +-- +-- **Nullable, and every existing row keeps NULL**: the news pipeline's jobs are +-- not an event's, and nothing about how they are enqueued, retried or rolled up +-- changes. What this buys is that `core.announce.post` may enqueue a SECOND job +-- for a post that has already been announced -- the common case, since the post +-- an event announces is very often the news post that announced it -- without +-- either colliding with the first or overwriting `posts.announce_job_id`, which +-- is the back-pointer the post admin panel's retry button reads. +-- +-- **No foreign key, exactly like `posts.announce_job_id` beside it.** An announce +-- job that went out is a delivery record and must outlive whatever asked for it, +-- and `ADD CONSTRAINT ... FOREIGN KEY` has no `IF NOT EXISTS` in MariaDB -- so a +-- constraint here would be the one statement in this file that cannot replay. +-- The column is read only to answer "which run announced this", and a run id +-- that no longer resolves answers that honestly. +ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL; +ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id); diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index bfa13b9..4c0ab06 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -2,6 +2,342 @@ "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", "moduleApiVersion": "1.10.0", "triggers": [ + { + "id": "event.phase.changed", + "owner": "core", + "label": "Event — a new phase", + "description": "An event that is under way has moved on to its next stage.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "phase", + "type": "string", + "required": true, + "example": "assault", + "description": "The phase key just entered, as authored in the spec." + }, + { + "name": "phaseLabel", + "type": "string", + "required": false, + "example": "The assault", + "description": "The phase label, when the spec gave it one. Falls back to the key." + }, + { + "name": "phaseIndex", + "type": "int", + "required": true, + "example": 2, + "description": "Which phase this is, counting from 1." + }, + { + "name": "phaseCount", + "type": "int", + "required": true, + "example": 4, + "description": "How many phases the pinned version has in total." + } + ] + }, + { + "id": "event.run.cancelled", + "owner": "core", + "label": "Event — cancelled", + "description": "A scheduled event was cancelled by a member of staff.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "reason", + "type": "string", + "required": false, + "example": "The shard is down for an emergency patch.", + "description": "What the staff member gave as the reason, when they gave one." + } + ] + }, + { + "id": "event.run.completed", + "owner": "core", + "label": "Event — finished", + "description": "An event has finished.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "summary", + "type": "string", + "required": false, + "example": "Orcish warbands are massing north of Yew.", + "description": "The event summary, as authored." + }, + { + "name": "participantCount", + "type": "int", + "required": true, + "example": 47, + "description": "How many participants the run recorded. Zero when nothing collected any." + }, + { + "name": "durationMinutes", + "type": "int", + "required": true, + "example": 95, + "description": "How long the run took, start to end, in whole minutes." + } + ] + }, + { + "id": "event.run.ending", + "owner": "core", + "label": "Event — winding down", + "description": "An event is drawing to a close.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + } + ] + }, + { + "id": "event.run.failed", + "owner": "core", + "label": "Event — run failed", + "description": "An event stopped before it finished.", + "kind": "event", + "subjectKey": "runId", + "audience": "admin", + "ceiling": "admin", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "phase", + "type": "string", + "required": false, + "example": "assault", + "description": "The phase it failed in, when it had entered one." + }, + { + "name": "error", + "type": "string", + "required": false, + "example": "sidecar responded 503", + "description": "The run’s last error, verbatim from the run row." + }, + { + "name": "runUrl", + "type": "url", + "required": true, + "example": "/admin/events/runs/3692", + "description": "Site-relative path to the run console." + } + ] + }, + { + "id": "event.run.scheduled", + "owner": "core", + "label": "Event — scheduled", + "description": "A new event has been added to the calendar.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "summary", + "type": "string", + "required": false, + "example": "Orcish warbands are massing north of Yew.", + "description": "The event summary, as authored." + }, + { + "name": "seriesName", + "type": "string", + "required": false, + "example": "The Yew Campaign", + "description": "The arc this event belongs to, when it belongs to one." + }, + { + "name": "startsAt", + "type": "datetime", + "required": true, + "example": "2026-09-12T20:00:00.000Z", + "description": "When the occurrence is due to start, UTC." + }, + { + "name": "timezone", + "type": "string", + "required": false, + "example": "America/New_York", + "description": "The shard-local zone the schedule was authored in." + }, + { + "name": "startsAtLabel", + "type": "string", + "required": false, + "example": "Saturday 12 September at 8:00 pm (America/New_York)", + "description": "The start time written out in the shard-local zone, for a mail to read." + } + ] + }, + { + "id": "event.run.started", + "owner": "core", + "label": "Event — starting now", + "description": "A scheduled event has begun.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "summary", + "type": "string", + "required": false, + "example": "Orcish warbands are massing north of Yew.", + "description": "The event summary, as authored." + }, + { + "name": "seriesName", + "type": "string", + "required": false, + "example": "The Yew Campaign", + "description": "The arc this event belongs to, when it belongs to one." + }, + { + "name": "startsAt", + "type": "datetime", + "required": true, + "example": "2026-09-12T20:00:00.000Z", + "description": "When it actually started, UTC." + }, + { + "name": "timezone", + "type": "string", + "required": false, + "example": "America/New_York", + "description": "The shard-local zone the schedule was authored in." + }, + { + "name": "startsAtLabel", + "type": "string", + "required": false, + "example": "Saturday 12 September at 8:00 pm (America/New_York)", + "description": "The start time written out in the shard-local zone, for a mail to read." + } + ] + }, { "id": "news.post", "owner": "core", diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js index e124ac7..b972530 100644 --- a/server/src/config/coreEventActions.js +++ b/server/src/config/coreEventActions.js @@ -22,6 +22,16 @@ // verb per module would be that bound re-implemented once per module, advisory // everywhere, and wrong in the first one that forgot it. // +// **Phase 10 added the last two, and they are the integrations** (EVENTS.md +// §J). `core.announce.post` sends an ARTICLE rather than a line — it links a +// post an editor already wrote and queues it through `announce_jobs`, so the +// town crier and Discord arrive as already-registered legs with their retry and +// their classification rather than as a second delivery pipeline. And +// `core.results.publish` is what makes §F's "publish results" literal: it ranks +// the run's participants and stamps the table published. Both name a game noun +// nowhere, which is why they are core's; six actions is now the whole of what an +// event can do on a deployment with no game module installed at all. +// // **Phase 2 gave all three real bodies**, and between them they exercise every // shape §F's envelope can take: `core.announce` does work and finishes, // `core.wait` finishes while deferring what follows it, and `core.cue` succeeds @@ -413,6 +423,142 @@ const ACTIONS = [ return { ok: true } }, }, + + { + id: 'core.announce.post', + label: 'Announce a post', + description: + 'Send an existing news post out on every registered announce leg — Discord, the in-game town crier — as this run\'s announcement.', + + // Nothing in the world changes and nothing is created; a message goes out. + // Same class as `core.announce` and for the same reason. + risk: 'notify', + // The job is queued, the legs deliver, and none of it can be unsent. A + // `ledger` here would put a row in the cleanup ledger that teardown could + // never resolve. + reversible: 'none', + version: 1, + + // **`core.announce` sends a line; this sends an ARTICLE**, and that is the + // whole difference between them (EVENTS.md §J, "News"). Events does not + // write posts — `ctx.posts` is read-only to modules and the CMS is core's — + // so an event that wants prose, an image and a permanent page links a post + // an editor already wrote. What this action adds over `core.announce` is + // therefore not a second transport but a second SHAPE: every leg's + // `dispatch()` takes a post, and this is the one that hands it a real one. + params: [ + { + name: 'postId', + type: 'int', + required: true, + example: 412, + source: 'core.options.posts', + description: 'The published post to announce. Any category.', + }, + ], + + /** + * Queue the post on every registered leg, as this run's announcement. + * + * **The refusals are all `retry: false`**, and each is a thing a human has to + * fix: a post id that names nothing, or a draft. Neither will have changed + * sixty seconds later, and retrying would spend two more attempts before + * saying the same thing. + * + * **What it does NOT wait for is delivery.** `enqueueForRun` writes the job + * and the legs and returns; `announceWorker` drains them on its own tick with + * its own backoff. So this step is `done` when the announcement is queued, + * not when Discord has it — which is honest, because a leg that fails after + * six attempts over two hours is not something a step could usefully have + * stayed open for, and the post admin panel is where that failure is already + * surfaced. + */ + async perform({ runId, params, verify }) { + /* eslint-disable global-require */ + const posts = require('../model/posts/posts.model') + const announceJobs = require('../model/announceJobs/announceJobs.model') + /* eslint-enable global-require */ + + const postId = Number(params.postId) + if (!Number.isInteger(postId) || postId < 1) { + return { ok: false, retry: false, error: `"${params.postId}" is not a post id` } + } + + const post = await posts.getById(postId) + if (!post) return { ok: false, retry: false, error: `no post with id ${postId}` } + if (!post.published) { + // A draft has no public page for a town-crier line to point at, and + // announcing one would publish its title to a shard before an editor + // meant to. Refused rather than published on the author's behalf: + // publishing is the CMS's decision and this action is not it. + return { ok: false, retry: false, error: `"${post.title}" is not published` } + } + + // The dry run has now checked everything worth checking — the post exists + // and is published — and queues nothing. Checked BEFORE the legs are read, + // because a deployment with no leg registered is a real state and a verify + // that reported it as a failure would refuse a plan that is fine. + if (verify) return { ok: true } + + await announceJobs.enqueueForRun(postId, runId) + return { ok: true } + }, + }, + + { + id: 'core.results.publish', + label: 'Publish the results', + description: + 'Rank this run\'s participants by score and publish the results table.', + + // Nothing in the game world changes and nobody is messaged: a table core + // already holds becomes readable. `inspect` is the weakest class the closed + // set has and it is the honest one — which also means this action is + // default-ON like `core.wait`, and an author can place it without an admin + // first visiting the switchboard. + risk: 'inspect', + // **`none`, and it is worth saying why a publication is not reversible.** + // Nothing is created that core would have to come back for; un-publishing is + // an admin decision about a table, not a teardown obligation, and a `ledger` + // row here would make every completed event carry an outstanding resource + // for ever. + reversible: 'none', + version: 1, + + // No params. What is published is this run's participants, which is the only + // set there is — a param naming which run would be a way to publish someone + // else's results from inside your own event. + params: [], + + /** + * Rank, stamp, and say how many. + * + * **Idempotent by construction**, which is what makes it safe as an ordinary + * retried step: ranking is a total order over `(score, joined_at, id)`, so + * running it twice over an unchanged table writes the same numbers, and the + * stamp simply moves. A late participant added by a second collect step and + * a re-publish afterwards renumbers deliberately — that is the operator + * asking for exactly that. + * + * **A run with no participants publishes an empty table rather than + * failing.** "Nobody was recorded" is a true and renderable result, and it is + * the state of every run until a module can source attendance at all (Phase + * 12). Failing here would make an event whose module reports nothing look + * broken on the console for a reason that has nothing to do with the event. + */ + async perform({ runId, verify }) { + /* eslint-disable global-require */ + const participantsDb = require('../model/events/eventRunParticipants.db') + const runsDb = require('../model/events/eventRuns.db') + /* eslint-enable global-require */ + + if (verify) return { ok: true } + + await participantsDb.rankRun(runId) + await runsDb.markResultsPublished(runId) + return { ok: true } + }, + }, ] // ── Core's own param option sources (§F, Phase 7) ────────────────── @@ -446,6 +592,28 @@ const OPTION_SOURCES = [ .map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] })) }, }, + { + id: 'core.options.posts', + label: 'Published posts', + description: 'Every published post an event may announce, newest first.', + /** + * **The one option source in core that reaches a table**, and the reason it + * is allowed to is the rule §F draws about WHEN: a source resolves on its own + * request (`GET /admin/events/catalog/options/:sourceId`), which is a live + * request on a booted server, not at `register()` time under a dead pool. + * + * Grouped by category so the dropdown separates news from the newsletter + * rather than presenting one long list in which the two are indistinguishable + * — a `group` is what the form renders as an optgroup, and it costs a column + * that is already selected. + */ + async resolve() { + // eslint-disable-next-line global-require + const postsDb = require('../model/posts/posts.db') + const rows = await postsDb.listPublishedForOptions(200) + return rows.map((p) => ({ value: p.id, label: p.title, group: p.category })) + }, + }, ] module.exports = { ACTIONS, OPTION_SOURCES } diff --git a/server/src/config/coreStreams.js b/server/src/config/coreStreams.js index 20e3bc1..e9fa49d 100644 --- a/server/src/config/coreStreams.js +++ b/server/src/config/coreStreams.js @@ -75,6 +75,38 @@ const STREAMS = [ personal: false, requiresLinkedAccount: false, }, + + // ── The event system (EVENTS.md §J — Phase 10) ────────────────────────── + // + // **One of the seven `event.` triggers is also a stream, and that is a + // decision rather than an oversight** (org lead, 2026-09-04). A stream is a + // PUSH toggle: `notificationChannelPrefs.catalog` offers the push channel only + // for ids registered here, `publishToUsers` joins `notification_subscriptions`, + // and that table is only ever written for a channel a user could switch on. So + // a trigger that is not also a stream can be mailed and put in the inbox, and + // its push is dead — a tickle published to nobody, which the send log + // nonetheless records as sent. Found on the live rig; the seeded rule named + // `push` before this line existed. + // + // **`run.started` alone, because push is the channel that says "now".** It is + // the one lifecycle moment worth waking a phone for — ENGAGEMENT.md §8.5's + // *"come back for X"* — and the other six are things a player reads when they + // next look. Six more toggles would put a wall of switches on the preferences + // screen for one feature, and `event.phase.changed` is the one most likely to + // buzz a phone four times in an evening. + // + // Same id as the trigger, which is §7.2's one namespace and the same-owner + // upgrade `news.post` already is: one id, one owner, two facets. + { + id: 'event.run.started', + label: 'Events — starting now', + description: 'A scheduled event is beginning.', + // Not owner-keyed: this is a public event happening in public, not a fact + // about one account's own property. Same as `news.post`. + personal: false, + // A player with no linked game account can still want to know an event is on. + requiresLinkedAccount: false, + }, ] module.exports = { STREAMS } diff --git a/server/src/config/coreTriggers.js b/server/src/config/coreTriggers.js index d9ff10a..eba59d7 100644 --- a/server/src/config/coreTriggers.js +++ b/server/src/config/coreTriggers.js @@ -149,6 +149,232 @@ const TRIGGERS = [ description: 'Site-relative path to the announcement.' }, ], }, + + // ── The event system (EVENTS.md §J — Phase 10) ────────────────────────── + // + // **Seven triggers, one per moment a run passes through that somebody outside + // the run console might want to hear about — and Events owns none of the + // delivery.** A run emits; an operator's rule decides who is told, on what, + // and how often. That is the whole of §J's "clean fit" row, and it is why + // there is no announcement machinery anywhere in `utils/eventRunner.js` + // beyond a call to `emit`. + // + // **Six are ceilinged `authenticated` and one at `admin`** (§J, and the org + // lead 2026-09-04). `run.failed` is an operational fact — a step ran out of + // attempts, the world may be half-changed — and a rule that mailed it to + // every subscriber would publish the deployment's incidents. The other six + // describe a public event happening in public, so they sit exactly where + // `news.post` sits: ceiling `authenticated`, default audience `subscribers`, + // which is "people who asked to be told" rather than the whole user table. + // + // **Every `description` here is read by two audiences**, and the second one is + // easy to forget: the rule editor's catalog, and — through + // `projection.project`'s `intro` fallback — every recipient of an unauthored + // render through `notify.event` or `inapp.event`. So each is prose a player + // can read rather than a note to the operator. The live rig caught the + // original `run.failed` line, which ended "Staff-facing." and put those words + // in an administrator's own inbox item. Who a trigger is for is said by its + // CEILING, which is the only place that can enforce it anyway. + // + // **`subjectKey: 'runId'` on every one of them**, and it is the one place + // these differ from `news.post`. A cooldown keyed on the user would make + // `phase.changed` mean "at most one phase of at most one event an hour", + // silently swallowing the second wave of an invasion because the first wave's + // mail went out forty minutes ago. Keyed on the run it means "at most one + // line an hour ABOUT THIS RUN", which is the useful sentence — and across + // runs of the same definition the ids differ, so a weekly event is not + // throttled by last week's. + // + // **None of the six public ones declares a `url` variable, deliberately.** + // There is no public event page until Phase 14 — `App.jsx` mounts nothing + // under `/site/events` — and `news.post` has already paid for this mistake + // once: its `postUrl` example named `/news/`, a path that does not + // exist, and the template editor previewed a link that was dead in every mail + // it sent. A variable added in Phase 14 alongside the page it points at is a + // version bump; a variable shipped now is a 404 in an operator's first + // announcement. `run.failed` is the exception because its destination exists + // today: `/admin/events/runs/:runId` is a real route and an admin can read it. + { + id: 'event.run.scheduled', + label: 'Event — scheduled', + description: 'A new event has been added to the calendar.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.', + description: 'The event summary, as authored.' }, + { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign', + description: 'The arc this event belongs to, when it belongs to one.' }, + { name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z', + description: 'When the occurrence is due to start, UTC.' }, + { name: 'timezone', type: 'string', required: false, example: 'America/New_York', + description: 'The shard-local zone the schedule was authored in.' }, + // **A presentational fragment, and §4.6.1 convention 1 is what sanctions + // one.** `startsAt` is a `datetime`, which the seam normalises to an ISO + // string — correct as data and unreadable in a mail, and a template has no + // logic with which to format it. So the formatting happens at the emitter, + // in the shard-local zone, and arrives as a variable whose `example` shows + // exactly what it produces. Same trade `forWhom` makes in the auth bodies. + { name: 'startsAtLabel', type: 'string', required: false, + example: 'Saturday 12 September at 8:00 pm (America/New_York)', + description: 'The start time written out in the shard-local zone, for a mail to read.' }, + ], + }, + { + id: 'event.run.started', + label: 'Event — starting now', + description: 'A scheduled event has begun.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.', + description: 'The event summary, as authored.' }, + { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign', + description: 'The arc this event belongs to, when it belongs to one.' }, + { name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z', + description: 'When it actually started, UTC.' }, + { name: 'timezone', type: 'string', required: false, example: 'America/New_York', + description: 'The shard-local zone the schedule was authored in.' }, + // **A presentational fragment, and §4.6.1 convention 1 is what sanctions + // one.** `startsAt` is a `datetime`, which the seam normalises to an ISO + // string — correct as data and unreadable in a mail, and a template has no + // logic with which to format it. So the formatting happens at the emitter, + // in the shard-local zone, and arrives as a variable whose `example` shows + // exactly what it produces. Same trade `forWhom` makes in the auth bodies. + { name: 'startsAtLabel', type: 'string', required: false, + example: 'Saturday 12 September at 8:00 pm (America/New_York)', + description: 'The start time written out in the shard-local zone, for a mail to read.' }, + ], + }, + { + id: 'event.phase.changed', + label: 'Event — a new phase', + description: 'An event that is under way has moved on to its next stage.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + { name: 'phase', type: 'string', required: true, example: 'assault', + description: 'The phase key just entered, as authored in the spec.' }, + { name: 'phaseLabel', type: 'string', required: false, example: 'The assault', + description: 'The phase label, when the spec gave it one. Falls back to the key.' }, + { name: 'phaseIndex', type: 'int', required: true, example: 2, + description: 'Which phase this is, counting from 1.' }, + { name: 'phaseCount', type: 'int', required: true, example: 4, + description: 'How many phases the pinned version has in total.' }, + ], + }, + { + id: 'event.run.ending', + label: 'Event — winding down', + description: 'An event is drawing to a close.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + ], + }, + { + id: 'event.run.completed', + label: 'Event — finished', + description: 'An event has finished.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.', + description: 'The event summary, as authored.' }, + // Counted from `event_run_participants` at emit. Zero on a run whose + // module reported nobody, which is every run until a module collects — + // a template that says "47 took part" needs a number that is never + // missing, and "0" is the honest one. + { name: 'participantCount', type: 'int', required: true, example: 47, + description: 'How many participants the run recorded. Zero when nothing collected any.' }, + { name: 'durationMinutes', type: 'int', required: true, example: 95, + description: 'How long the run took, start to end, in whole minutes.' }, + ], + }, + { + id: 'event.run.cancelled', + label: 'Event — cancelled', + description: 'A scheduled event was cancelled by a member of staff.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + // **The operator's reason, and not the run's `last_error`.** `cancel` + // takes a `{ reason }` a human typed for other humans; a diagnostic + // string is for the run console and would read as gibberish in a mail. + { name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.', + description: 'What the staff member gave as the reason, when they gave one.' }, + ], + }, + { + id: 'event.run.failed', + label: 'Event — run failed', + description: 'An event stopped before it finished.', + kind: 'event', + subjectKey: 'runId', + // **`admin`, and both halves of that.** The ceiling is the security + // boundary (§J, G24): no rule may ever widen this past admins, because a + // failure names the deployment's own broken machinery. The default audience + // matches, so a rule created from this trigger starts where it must end. + audience: 'admin', + ceiling: 'admin', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + { name: 'phase', type: 'string', required: false, example: 'assault', + description: 'The phase it failed in, when it had entered one.' }, + { name: 'error', type: 'string', required: false, example: 'sidecar responded 503', + description: 'The run’s last error, verbatim from the run row.' }, + // The one url variable in this file's Phase 10 block, and the reason is + // that this route exists TODAY. See the note above the six. + { name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692', + description: 'Site-relative path to the run console.' }, + ], + }, ] module.exports = { TRIGGERS } diff --git a/server/src/engagement/audiences.js b/server/src/engagement/audiences.js index 29a9285..ff10a76 100644 --- a/server/src/engagement/audiences.js +++ b/server/src/engagement/audiences.js @@ -156,11 +156,27 @@ async function resolveForRule(rule, event) { * same id more tightly. That is precisely the case where a stale rule would * otherwise mail a population the current declaration forbids, which is what * makes this the security boundary rather than a duplicate check. + * + * **`emitted` is the second thing this gate now weighs** (Phase 10). A firing may + * carry a ceiling of its own — a rehearsal's `staff` (EVENTS.md §I) — and the + * effective bound is the MEET of the two, so a firing can only ever narrow what + * the declaration allows. Two incomparable ceilings meet to null and the gate + * refuses: `owner` and `staff` have no common descendant, and picking one would + * be the guess §5.1a rule 3 exists to refuse. That is also why an unknown value + * cannot get here — `emit` validates it against the same lattice — but the null + * is handled anyway, because this is the boundary and a boundary that trusts its + * caller is not one. + * + * @param {string} triggerId + * @param {string} ceiling the audience the rule resolved to + * @param {string|null} [emitted] a narrowing ceiling this firing carries */ -function permitted(triggerId, ceiling) { +function permitted(triggerId, ceiling, emitted = null) { const declaration = registries.eventTrigger(triggerId) if (!declaration) return false - return ceilings.permits(declaration.ceiling, ceiling) + const bound = emitted ? ceilings.meet(declaration.ceiling, emitted) : declaration.ceiling + if (!bound) return false + return ceilings.permits(bound, ceiling) } module.exports = { resolveForRule, permitted, defaultOnChannels } diff --git a/server/src/engagement/coreRules.js b/server/src/engagement/coreRules.js index e819f3d..f573469 100644 --- a/server/src/engagement/coreRules.js +++ b/server/src/engagement/coreRules.js @@ -1,4 +1,4 @@ -// ── The five rules core ships, all of them OFF ───────────────────────────── +// ── The seven rules core ships, all of them OFF ──────────────────────────── // // ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed // people with no operator configuration at all: the code decided who was mailed @@ -34,6 +34,11 @@ // on and no way to tell why. One key per seed GROUP is the rule this establishes; // a sixth rule for a new trigger takes a sixth key, and a rule added to an // existing group is a rule that only fresh installs will ever see. +// +// **EVENTS.md Phase 10 added two more, and a third key**, for the event +// lifecycle: `event.run.started` and `event.run.failed`. Same argument, third +// application — a deployment that has already stamped the news key must still +// see these. const rulesDb = require('../model/engagement/engagementRules.db') const settingsDb = require('../model/settings/settings.db') @@ -46,6 +51,9 @@ const SEEDED_KEY = 'engagement_team_rules_seeded' // Phase 11's, and separate for the reason above. Same shape, same semantics. const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded' +// EVENTS.md Phase 10's, third group, third key. +const EVENT_SEEDED_KEY = 'engagement_event_rules_seeded' + const RULES = [ { trigger_id: 'team.forum.post', @@ -145,6 +153,73 @@ const NEWS_RULES = [ }, ] +// Phase 10's two, in their own list under their own one-shot key — the rule +// Phase 11 established, applied for the second time. Appending to `NEWS_RULES` +// would seed these on fresh installs only and on exactly the upgrades that want +// them, never. +// +// **Two rules for seven triggers, and that is the whole decision** (org lead, +// 2026-09-04). Every one of the seven is declared, so an operator can write a +// rule against any of them from the rules screen; what is SEEDED is the pair +// somebody would otherwise have to build from scratch on the first day — the +// player-facing "it is starting" and the staff-facing "it broke". Seeding all +// seven would grow Admin → Engagement → Rules by seven disabled rows nobody +// asked for, and `event.phase.changed` is the one most likely to be switched on +// by accident and then mail a player four times in one evening. +const EVENT_RULES = [ + { + trigger_id: 'event.run.started', + name: 'Events — starting now', + // `subscribers`, the trigger's own default: people who opted into this id on + // at least one channel. Not `authenticated`, even though the ceiling permits + // it — an event is worth telling people who asked to be told about events, + // and mailing the whole user table every Saturday night is how a feature + // earns a spam complaint. An operator who wants the whole site can widen it; + // the ceiling is what stops them widening it past that. + audience: 'subscribers', + // All three, like the news rule and for the same reason: push is the channel + // that gets somebody to log in *now*, which is the entire point of a + // "come back for this" notice (ENGAGEMENT.md §8.5), and the in-app inbox is + // the surface a content-free tickle deep-links into. + channels: ['email', 'inapp', 'push'], + // The one bespoke body this phase seeds; see `templateSeeds.js` for why it + // is one and not seven. `inapp.event` is the in-app renderer's generic, and + // push carries no content by construction and needs no template. + template_keys: { email: 'notify.event-started', inapp: 'inapp.event', digest: 'notify.digest' }, + // An hour, per user PER RUN — `event.run.started` declares `subjectKey: + // 'runId'`, so the cooldown subject is the run and not the recipient. It is + // near-redundant on a trigger that fires once per run, which is the point: + // it costs nothing and it is the guard if a run is ever restarted. + cooldown_seconds: 3600, + max_sends_per_hour: 1000, + }, + { + trigger_id: 'event.run.failed', + name: 'Events — a run failed', + // `admin`, which is both the trigger's default and its ceiling. A failed run + // names the deployment's own broken machinery — a sidecar that did not + // answer, a step that ran out of attempts — and there is no widening of this + // that is not a disclosure. + audience: 'admin', + // No push. An admin's phone buzzing at four in the morning for a step that + // will still be failed at breakfast is a notification people switch off + // wholesale, and switching it off wholesale is how the one that mattered is + // missed. Mail and the inbox both wait. + channels: ['email', 'inapp'], + // The generic body plus the structural projection: `event.run.failed` + // declares its own `title` and a `runUrl`, so an unauthored mail is already + // headed with the event's name and buttoned through to the run console — + // §4.6.1 property 1, working exactly as it promises. + template_keys: { email: 'notify.event', inapp: 'inapp.event' }, + // **No cooldown, and this is the one rule in the file that must not have + // one.** The subject is the run, so a cooldown would only ever suppress a + // second failure of the SAME run — which is precisely the run an + // administrator most needs the second line about. + cooldown_seconds: 0, + max_sends_per_hour: 200, + }, +] + /** * Seed one group of rules, once, under its own guard key. * @@ -185,7 +260,7 @@ async function seedGroup(key, rules, note) { }) summary.inserted += 1 } catch (err) { - log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message }) + log.error('rule seed failed', { key, trigger: rule.trigger_id, message: err.message }) } } // Stamped even on a partial run — the claim above is the stamp. Re-running @@ -209,6 +284,10 @@ const seedTeamRules = () => const seedNewsRule = () => seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule') +/** The two event-lifecycle rules (EVENTS.md Phase 10). */ +const seedEventRules = () => + seedGroup(EVENT_SEEDED_KEY, EVENT_RULES, 'Event notifications stay off until an operator enables one') + /** * Both groups, which is what the boot path calls. * @@ -219,9 +298,10 @@ const seedNewsRule = () => async function seedCoreRules() { const team = await seedTeamRules() const news = await seedNewsRule() + const events = await seedEventRules() return { - inserted: team.inserted + news.inserted, - skipped: team.skipped + news.skipped, + inserted: team.inserted + news.inserted + events.inserted, + skipped: team.skipped + news.skipped + events.skipped, } } @@ -229,8 +309,11 @@ module.exports = { seedCoreRules, seedTeamRules, seedNewsRule, + seedEventRules, RULES, NEWS_RULES, + EVENT_RULES, SEEDED_KEY, NEWS_SEEDED_KEY, + EVENT_SEEDED_KEY, } diff --git a/server/src/engagement/engine.js b/server/src/engagement/engine.js index e1447c1..52d3b88 100644 --- a/server/src/engagement/engine.js +++ b/server/src/engagement/engine.js @@ -150,13 +150,21 @@ async function applyRule(rule, event, now) { // G24, re-run at send time. A rule saved when its trigger permitted a wider // audience must not keep reaching it after a module upgrade narrowed the - // declaration - and that is the only way this can fail, since the save path + // declaration - and that was the only way this could fail, since the save path // ran the same check. - if (!audiences.permitted(event.triggerId, resolved.ceiling)) { + // + // **Phase 10 gave it a second way, and it is the one that fires in practice:** + // the event may carry a narrowing ceiling of its own. A rehearsal emits + // `event.run.started` with `ceiling: 'staff'`, and every rule an operator wrote + // for the real thing is then refused here rather than mailing subscribers about + // an event that is not happening. Nothing about the rule changed; the occasion + // did. See `audiences.permitted`. + if (!audiences.permitted(event.triggerId, resolved.ceiling, event.ceiling)) { log.warn('rule audience exceeds its trigger ceiling - refusing', { rule: rule.id, trigger: event.triggerId, audience: resolved.ceiling, + emitted: event.ceiling || null, }) summary.skipped = 'ceiling' return summary diff --git a/server/src/engagement/templateSeeds.js b/server/src/engagement/templateSeeds.js index d77e0f0..2088c40 100644 --- a/server/src/engagement/templateSeeds.js +++ b/server/src/engagement/templateSeeds.js @@ -310,6 +310,56 @@ const SEEDS = [ button('cta', 'Open', '{{actionUrl}}'), ], }, + // ── The event system (EVENTS.md §J — Phase 10) ───────────────────────── + // + // **One body, not seven.** Six of the seven `event.` triggers render through + // `notify.event` and the structural projection with no authoring at all + // (§4.6.1 property 1) — they declare their own `title`, so an unauthored mail + // is already headed with the event's name — and seeding a bespoke body per + // trigger would be seven templates an operator has to maintain to change one + // sentence. + // + // `event.run.started` gets one because it is the flagship: the mail that + // answers §8.5's *"Come back for X — a scheduled event is starting"*, the one + // an operator will actually enable, and the one where the generic body reads + // visibly worse — `notify.event` renders the title over the TRIGGER's + // description, while this reads the payload's own names and says what is + // starting, when, and what arc it belongs to. Same argument `notify.team-post` + // makes beside the generic body, one feature along. + // + // **Every optional line is one token on its own**, which is this template + // language's whole conditional (see `email.text`: a block whose content is a + // single absent variable renders nothing, in both parts). A standalone event + // has no `seriesName` and its line disappears rather than reading "Part of .". + // + // **No `{{actionUrl}}` and no button, deliberately.** There is no public event + // page until Phase 14, so the six public triggers declare no `url` variable at + // all (see `coreTriggers.js`), and a button here would render as an inert grey + // label in every mail — worse than none, because it advertises a link the + // reader cannot follow. Phase 14 adds the variable and the block together. + { + key: 'notify.event-started', + name: 'Event starting', + channel: 'email', + protected: false, + seedVersion: 1, + subject: '{{title}} is starting', + variables: [ + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion' }, + { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' }, + { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' }, + { name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' }, + { name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' }, + ], + blocks: [ + heading('h', '{{title}}'), + text('summary', '{{summary}}'), + text('when', '{{startsAtLabel}}', { muted: true }), + text('series', '{{seriesName}}', { muted: true }), + divider('rule'), + button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'), + ], + }, ] /** @returns {object|null} the seed definition for `key`. */ diff --git a/server/src/events/announce.js b/server/src/events/announce.js new file mode 100644 index 0000000..e4813f0 --- /dev/null +++ b/server/src/events/announce.js @@ -0,0 +1,225 @@ +// ── A run's lifecycle, told to the engagement engine ─────────────────────── +// +// EVENTS.md §J, and Phase 10 of EVENTS_PLAN.md. Seven moments in a run's life +// become seven `event.` triggers, and **Events owns none of the delivery**. +// +// That sentence is the whole design and it is worth being exact about what it +// buys. Nothing in this file knows what email is, whether anyone is subscribed, +// what a template says, or how often somebody may be mailed. It says a thing +// happened, with the facts the declaration asked for; an operator's rule decides +// the rest. Every announcement channel the platform has — email, the in-app +// inbox, content-free push tickles, Discord and the town crier through the +// announce legs — arrives for free the day a rule points at one, and none of +// them arrives by anything in `events/` growing a second delivery path. +// +// **Nothing here throws and nothing here is awaited for its answer.** `emit` +// itself is fire-and-forget by construction (see `engagementEmit`'s header) — +// the whole point of the seam is that the emitter does not wait on rule lookups +// and a dozen inserts. What IS awaited here is the read that assembles the +// payload, and it is wrapped: a run must not fail to start because the row that +// says what it is called could not be read. +// +// **A rehearsal narrows the ceiling rather than staying silent.** §I: "run for +// real with announcements ceilinged to `staff`". Every emit below carries +// `ceiling: 'staff'` when the run is a rehearsal, so the same triggers fire, the +// same rules are evaluated, the same log lines are written — and the only rules +// that survive the G24 gate are ones whose audience a staff member is in. A +// rehearsal that emitted nothing would be a rehearsal of everything except the +// announcements, which are the part most worth rehearsing. + +const definitionsDb = require('../model/events/eventDefinitions.db') +const participantsDb = require('../model/events/eventRunParticipants.db') +const logDb = require('../model/events/eventRunLog.db') +const engagementEmit = require('../utils/engagementEmit') +const log = require('../utils/logger')('events') + +// §I, and the one place a rehearsal differs from the real thing on the announce +// path. `staff` rather than `admin` because a rehearsal is the event team's +// dress run and a moderator on it should see what an attendee would. +const REHEARSAL_CEILING = 'staff' + +/** + * The start time written out in the shard-local zone, for a mail to read. + * + * **A presentational fragment computed at the emitter** (ENGAGEMENT.md §4.6.1 + * convention 1). `startsAt` also goes down the wire as a `datetime`, which the + * seam normalises to an ISO string — right as data and wrong in a sentence — and + * a template has no logic with which to format one. The zone is the shard's own, + * because "8pm" means the shard's evening to everyone reading it and the + * recipient's browser is not in the room when a mail is rendered. + * + * A bad zone answers null rather than throwing: `Intl` rejects an unknown + * identifier, and an event whose timezone column holds a typo must still + * announce. The variable is optional and its block is one token, so an absent + * label renders as nothing at all rather than as a broken line. + */ +function startsAtLabel(at, zone) { + const when = at instanceof Date ? at : new Date(at) + if (Number.isNaN(when.getTime())) return undefined + try { + const text = new Intl.DateTimeFormat('en-GB', { + timeZone: zone || 'UTC', + weekday: 'long', + day: 'numeric', + month: 'long', + hour: 'numeric', + minute: '2-digit', + // Explicit rather than left to the locale, because `en-GB` would otherwise + // render midnight as "00:00" while the schedule editor beside it writes + // "12:00 AM" — one event, two spellings of the same instant. + hour12: true, + }).format(when) + return `${text} (${zone || 'UTC'})` + } catch { + return undefined + } +} + +/** + * The facts every `event.` trigger shares, read once per emit. + * + * A run row from `findDue` is `SELECT *` over `event_runs` alone — no title, no + * series, no summary — so the definition is fetched here rather than threaded + * through every call site in the runner. It is one indexed read per lifecycle + * transition, which is a handful per run. + */ +async function baseFor(run) { + const definition = await definitionsDb.getById(run.definition_id) + if (!definition) return null + return { + runId: String(run.id), + title: definition.title, + summary: definition.summary || undefined, + seriesName: definition.series_name || undefined, + timezone: run.timezone || definition.timezone || undefined, + definition, + } +} + +/** + * Fire one lifecycle trigger. + * + * `extra` is merged over the shared facts and may drop any of them — a payload + * key set to `undefined` is simply absent, and `validatePayload` treats absent + * and null alike, so a trigger that declares fewer variables than this assembles + * is not a problem: undeclared keys are dropped at the seam and logged as a + * debug line rather than refused. + * + * Answers nothing. Every caller is a transition in the runner and none of them + * has anything it could correctly do with a failure of core's own notification + * bookkeeping. + */ +async function fire(run, triggerId, extra = {}) { + try { + const base = await baseFor(run) + if (!base) { + // The definition is gone. `event_runs.definition_id` cascades on delete, so + // this is a race with an archive rather than an ordinary state — nothing to + // announce and nothing broken. + return + } + const { definition, ...facts } = base + const ceiling = run.rehearsal ? REHEARSAL_CEILING : undefined + + engagementEmit.emit('core', triggerId, { + // The run, not the definition. Two occurrences of a weekly event are two + // subjects, so last week's mail does not throttle this week's — and within + // one run a cooldown means "at most one line an hour about THIS", which is + // the sentence an operator writing `phase.changed` actually wants. + subject: String(run.id), + // Bounded and stable, because it ends up in a signed unsubscribe token that + // will sit in a mailbox for months. A run id is both. + scopeKey: `event:${run.id}`, + ceiling, + data: { ...facts, ...extra }, + }) + + // Written here rather than at each call site: what an operator wants in the + // run log is that the run SAID something happened, and with what bound. How + // many people were told is the engagement engine's own log line and its own + // decision — a run log that claimed to know the number would be reporting a + // decision it does not make. + await logDb.write({ + runId: run.id, + kind: 'announcement.emitted', + phase: run.current_phase || null, + detail: { trigger: triggerId, ...(ceiling ? { ceiling, because: 'rehearsal' } : {}) }, + }) + } catch (err) { + log.error('lifecycle announcement failed', { run: run.id, trigger: triggerId, message: err.message }) + } +} + +// ── One function per moment, so the runner names a moment and not a payload ── +// +// The alternative — `fire(run, 'event.run.started', { … })` at each call site — +// would put the payload assembly in `eventRunner.js`, where a change to a +// declaration becomes a change to the runner. These are the seam. + +const runScheduled = (run) => + fire(run, 'event.run.scheduled', { + startsAt: run.scheduled_for, + startsAtLabel: startsAtLabel(run.scheduled_for, run.timezone), + }) + +const runStarted = (run, startedAt) => { + const at = startedAt || run.started_at || new Date() + return fire(run, 'event.run.started', { startsAt: at, startsAtLabel: startsAtLabel(at, run.timezone) }) +} + +const phaseChanged = (run, { phase, label, index, count }) => + fire(run, 'event.phase.changed', { + phase, + phaseLabel: label || phase, + // One-based, because it is read by a human in a sentence. Every caller + // passes the zero-based index it already has and the conversion is here, in + // one place, rather than at three call sites where two of them would drift. + phaseIndex: index + 1, + phaseCount: count, + }) + +const runEnding = (run) => fire(run, 'event.run.ending') + +async function runCompleted(run, endedAt) { + // Counted at emit rather than carried by the caller: the last thing a run does + // before completing is its teardown, and a module's collect step may have + // written rows within the same tick. + let participantCount = 0 + try { + participantCount = await participantsDb.countForRun(run.id) + } catch (err) { + // Declared `required`, so it has to be a number. Zero is the honest answer + // for a count that could not be read, and it is also the answer for the far + // more common case of a run nothing collected for. + log.warn('participant count unavailable for announcement', { run: run.id, message: err.message }) + } + const started = run.started_at ? new Date(run.started_at) : null + const ended = endedAt ? new Date(endedAt) : new Date() + const durationMinutes = started ? Math.max(0, Math.round((ended - started) / 60_000)) : 0 + return fire(run, 'event.run.completed', { participantCount, durationMinutes }) +} + +const runCancelled = (run, reason) => + fire(run, 'event.run.cancelled', { reason: reason || undefined }) + +const runFailed = (run, error) => + fire(run, 'event.run.failed', { + phase: run.current_phase || undefined, + error: error || run.last_error || undefined, + // The one destination that exists today. See `coreTriggers.js`'s note above + // the six public declarations for why none of them has one. + runUrl: `/admin/events/runs/${run.id}`, + }) + +module.exports = { + fire, + startsAtLabel, + runScheduled, + runStarted, + phaseChanged, + runEnding, + runCompleted, + runCancelled, + runFailed, + REHEARSAL_CEILING, +} diff --git a/server/src/events/dispatch.js b/server/src/events/dispatch.js index 3ac9e32..f1fa8aa 100644 --- a/server/src/events/dispatch.js +++ b/server/src/events/dispatch.js @@ -61,7 +61,8 @@ function withDeadline(fn, ms, actionId) { } /** - * Turn a raw `perform()` answer into `{ outcome, error?, holdSeconds?, resources? }`. + * Turn a raw `perform()` answer into + * `{ outcome, error?, holdSeconds?, resources?, participants? }`. * * Exported and pure, so the classification rules are testable without a registry, * a database or a clock — which matters because they are the rules that decide @@ -97,7 +98,12 @@ function classify(result, actionId) { // never names a verb. `core.cue` and `core.wait` reach them through the same // door Phase 7 opens to a module's own long-running action. if (result.await === 'human') { - return { outcome: 'parked', error: null, resources: result.resources || [] } + return { + outcome: 'parked', + error: null, + resources: result.resources || [], + participants: result.participants || [], + } } let holdSeconds = 0 @@ -109,7 +115,17 @@ function classify(result, actionId) { holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS) } - return { outcome: 'done', error: null, holdSeconds, resources: result.resources || [] } + // `participants` rides beside `resources` and on the same two success shapes + // (Phase 10). It is carried rather than interpreted here: what a member key + // means is the module's business, and this file's whole job is to know + // nothing about the verb it just called. + return { + outcome: 'done', + error: null, + holdSeconds, + resources: result.resources || [], + participants: result.participants || [], + } } /** diff --git a/server/src/events/participants.js b/server/src/events/participants.js new file mode 100644 index 0000000..c63efe9 --- /dev/null +++ b/server/src/events/participants.js @@ -0,0 +1,162 @@ +// ── Recording who took part ──────────────────────────────────────────────── +// +// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The twin of +// `events/ledger.js`: that file records what a run did to the world, this one +// records who it happened to. +// +// **Participants ride the SAME envelope resources do** (org lead, 2026-09-04). An +// action answers `{ ok: true, participants: [...] }` and the runner writes them +// beside the resources, on the same two success shapes, through the same +// classify → record path. There is no `ctx.events.participants` API and no route: +// a second write path into a run core is mid-tick on would be a second thing that +// can race the claim, for a caller that does not exist until a module can source +// the data at all (Phase 11's plugin-side participation ledger; Phase 12's +// collect step is the first consumer). +// +// **Core cannot source a participant and does not try.** §J: `member_key` is +// module-opaque, `user_id` is filled in by whoever knows the link table. For +// module-uo that is `shard_links`; for another game it is something else, and a +// core that guessed would be one game's identity model compiled into core. So a +// module reports both halves, or reports the key alone and the row stays +// anonymous — which is the honest record of an unlinked player who turned up. +// +// **A bad entry is dropped, never a retry.** Exactly `ledger.normalise`'s +// posture and for exactly its reason: a malformed participant will be just as +// malformed on the second attempt, and failing the step would re-dispatch a +// world write that already happened. Rejections are logged and surfaced on the +// run log so an author can see what their module sent. + +const participantsDb = require('../model/events/eventRunParticipants.db') +const log = require('../utils/logger')('events') + +// Bounded to the column, and refused rather than truncated: a truncated member +// key is a different participant, and under `uq_evpart_member` it would silently +// merge two people into one row. +const MAX_MEMBER_KEY = 190 + +// The most one step may report. A run's participants are people, and a step +// answering with a hundred thousand of them is a module bug rather than a very +// popular event — one that would otherwise spend a tick's whole budget on +// inserts while holding the step's claim. `MAX_AUDIENCE` in the engagement +// engine is 5000 for the same class of reason and this matches it deliberately: +// the two bound the same thing, a list of users one call may assert. +const MAX_PER_STEP = 5000 + +/** + * Turn one entry of a module's `participants` array into a row, or say why not. + */ +function normalise(entry, actionId) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return { ok: false, reason: `${actionId} reported a participant that is not an object` } + } + + const memberKey = String(entry.memberKey === undefined || entry.memberKey === null ? '' : entry.memberKey) + if (!memberKey || memberKey.length > MAX_MEMBER_KEY) { + return { ok: false, reason: `${actionId} reported a participant with a bad memberKey "${entry.memberKey}"` } + } + + // Optional, and checked rather than coerced. A `userId` is a foreign key into + // `users`, so a module that passed a character serial here would either fail + // the insert or — worse, if the number happened to be a real user — attribute + // somebody else's attendance to a stranger. + let userId = null + if (entry.userId !== undefined && entry.userId !== null) { + if (!Number.isInteger(entry.userId) || entry.userId < 1) { + return { ok: false, reason: `${actionId} reported a participant with a bad userId "${entry.userId}"` } + } + userId = entry.userId + } + + // `score` is optional and defaults to 0 — a run that only records attendance + // is a run where everybody scored nothing, which is a true statement and a + // renderable table. Non-finite is refused rather than coerced: `NaN` written + // into a DECIMAL would either throw at the driver or land as 0, and a 0 that + // meant "the module sent nonsense" is indistinguishable from an honest zero. + let score = 0 + if (entry.score !== undefined && entry.score !== null) { + const n = Number(entry.score) + if (!Number.isFinite(n)) { + return { ok: false, reason: `${actionId} reported a participant with a bad score "${entry.score}"` } + } + score = n + } + + let joinedAt = null + if (entry.joinedAt !== undefined && entry.joinedAt !== null) { + const at = entry.joinedAt instanceof Date ? entry.joinedAt : new Date(entry.joinedAt) + if (Number.isNaN(at.getTime())) { + return { ok: false, reason: `${actionId} reported a participant with a bad joinedAt "${entry.joinedAt}"` } + } + joinedAt = at + } + + return { + ok: true, + row: { + memberKey, + userId, + score, + // Opaque, exactly like a resource's payload: core stores it and never + // reads it. Anything but an object is dropped rather than refused — + // `meta` is decoration on a row whose identity is already valid, and + // losing an event's whole attendance over a stray string would be the + // wrong trade. + meta: entry.meta && typeof entry.meta === 'object' && !Array.isArray(entry.meta) ? entry.meta : null, + joinedAt, + }, + } +} + +/** + * Record what a module said about who took part. + * + * Answers `{ recorded, rejected }`. Never throws, for `recordAnswer`'s reason: a + * step that changed the world has changed it, and a bookkeeping failure must not + * become a retry of a world write. + */ +async function recordAnswer({ run, step, action, participants }) { + const out = { recorded: 0, rejected: [] } + const list = Array.isArray(participants) ? participants : [] + if (!list.length) return out + + if (list.length > MAX_PER_STEP) { + // Refused whole rather than truncated. Half a leaderboard silently cut at + // five thousand is worse than none: the table would look complete and be + // wrong, and nothing downstream could tell. + const reason = `${action.id} reported ${list.length} participants, more than the ${MAX_PER_STEP} one step may` + log.warn('event participants refused', { run: run.id, step: step.id, reason }) + return { recorded: 0, rejected: [reason] } + } + + // **Deduplicated in memory before the write.** One step reporting the same + // member twice is a module bug, and letting both reach the upsert would make + // the LAST one win silently. Refusing the step would be worse — the other + // ninety-nine participants are fine — so the first wins and the duplicate is + // named, which is a thing an author can act on. + const seen = new Set() + + for (const entry of list) { + const parsed = normalise(entry, action.id) + if (!parsed.ok) { + out.rejected.push(parsed.reason) + log.warn('event participant rejected', { run: run.id, step: step.id, reason: parsed.reason }) + continue + } + if (seen.has(parsed.row.memberKey)) { + out.rejected.push(`${action.id} reported "${parsed.row.memberKey}" twice in one step`) + continue + } + seen.add(parsed.row.memberKey) + try { + await participantsDb.record({ runId: run.id, ...parsed.row }) + out.recorded += 1 + } catch (err) { + out.rejected.push(err.message) + log.error('event participant insert failed', { run: run.id, step: step.id, message: err.message }) + } + } + + return out +} + +module.exports = { normalise, recordAnswer, MAX_MEMBER_KEY, MAX_PER_STEP } diff --git a/server/src/model/announceJobs/announceJobs.db.js b/server/src/model/announceJobs/announceJobs.db.js index 7c51a20..e4804d5 100644 --- a/server/src/model/announceJobs/announceJobs.db.js +++ b/server/src/model/announceJobs/announceJobs.db.js @@ -11,7 +11,7 @@ const { query } = require('../../utils/db') -const COLS = 'id, post_id, status, created_at, updated_at' +const COLS = 'id, post_id, run_id, status, created_at, updated_at' const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at' async function legsFor(jobIds) { @@ -34,8 +34,15 @@ async function attachLegs(jobs) { // Create a job and its leg rows in one go. `legs` is the registered leg id list — // an empty list is legal and yields a job with nothing to deliver. -async function create(postId, legs = []) { - const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId]) +// +// `runId` is Phase 10's (EVENTS.md §J): a job an EVENT asked for, rather than +// the one a news publish enqueues. It changes nothing about how the job is +// dispatched, retried or rolled up — the whole point of reusing this pipeline is +// that an event announcement gets the legs, the backoff and the classification +// already written — and everything it does change is in the two places below +// that ask "whose job is this". +async function create(postId, legs = [], { runId = null } = {}) { + const res = await query('INSERT INTO announce_jobs (post_id, run_id) VALUES (?, ?)', [postId, runId]) const jobId = Number(res.insertId) if (legs.length > 0) { const values = legs.map(() => '(?, ?)').join(', ') @@ -65,9 +72,15 @@ async function findById(id) { return (await attachLegs(rows))[0] } +// **The post's OWN job, which is what `run_id IS NULL` means here.** A post may +// now have more than one — the news publish enqueued one, and an event linked the +// same post later — and every caller of this function is the post admin panel or +// its retry button, which are about the news announcement. Without the clause the +// panel would silently start rendering an event's job the moment one existed, and +// the retry button would retry that instead. async function findByPostId(postId) { const rows = await query( - `SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`, + `SELECT ${COLS} FROM announce_jobs WHERE post_id = ? AND run_id IS NULL ORDER BY id DESC LIMIT 1`, [postId], ) if (rows.length === 0) return null diff --git a/server/src/model/announceJobs/announceJobs.model.js b/server/src/model/announceJobs/announceJobs.model.js index a82ffcd..31c63c2 100644 --- a/server/src/model/announceJobs/announceJobs.model.js +++ b/server/src/model/announceJobs/announceJobs.model.js @@ -45,6 +45,33 @@ async function enqueueIfNeeded(post, transition) { } } +/** + * Enqueue an announcement a RUN asked for (EVENTS.md §J, Phase 10). + * + * The same job, the same legs, the same worker — so the town crier and Discord + * come free, with their retry and their classification, rather than an event + * growing a second delivery pipeline that would need both again and get them + * subtly wrong. Two things differ, and both are about not standing on the news + * pipeline's toes: + * + * **The post's back-pointer is written only when it has none.** `announce_job_id` + * is what the post admin panel reads and what `shouldEnqueue` guards on, so + * moving it to an event's job would make a re-published post announce itself + * again. A post that has never been announced gains the pointer, because then + * this job IS its announcement and the panel should show it. + * + * **`announced_at` is not stamped by a run's job** — see `refreshStatus`. + * + * Returns the new job id. + */ +async function enqueueForRun(postId, runId) { + const jobId = await db.create(postId, registries.announceLegIds(), { runId }) + const post = await posts.getById(postId) + if (post && !post.announce_job_id) await posts.linkAnnounceJob(postId, jobId) + log.info('announce job enqueued for a run', { jobId, postId, runId }) + return jobId +} + // Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a // leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the // attempt count and schedule the next run (or fail the leg once the cap is hit). @@ -82,7 +109,12 @@ async function refreshStatus(jobId) { const status = logic.rollupStatus(job.legs.map((l) => l.status)) if (status !== job.status) await db.setStatus(jobId, status) job.status = status - if (status === 'done') { + // **A run's job does not stamp the post** (Phase 10). `announced_at` means + // "when this post was announced", and an event that links a three-week-old + // news article would otherwise rewrite that to today — making the post admin + // panel report a publication date it does not have. The event's own record of + // having announced is the run log line and the job's `run_id`. + if (status === 'done' && !job.run_id) { try { await posts.markAnnounced(job.post_id) } catch (err) { @@ -127,6 +159,7 @@ async function getByPostId(postId) { module.exports = { enqueue, + enqueueForRun, shouldEnqueue, enqueueIfNeeded, recordOutcome, diff --git a/server/src/model/events/eventRunControls.model.js b/server/src/model/events/eventRunControls.model.js index a0ad08e..23763fe 100644 --- a/server/src/model/events/eventRunControls.model.js +++ b/server/src/model/events/eventRunControls.model.js @@ -39,6 +39,7 @@ const runsDb = require('./eventRuns.db') const stepsDb = require('./eventRunSteps.db') const logDb = require('./eventRunLog.db') +const announce = require('../../events/announce') const gatesDb = require('./eventPhaseGates.db') const resourcesDb = require('./eventRunResources.db') const gates = require('../../events/gates') @@ -195,6 +196,16 @@ async function cancel(runId, { reason, cleanup = true } = {}, userId = null, { i }, }) + // **After the guarded transition, so exactly one caller announces** (Phase + // 10). Two moderators pressing cancel in the same second both reach the log + // write; only one of them wins `transition`, and the loser has already + // returned a 409 above. + // + // The operator's `reason`, not the run's `last_error` — `cancel` takes a + // sentence a human typed for other humans, and the diagnostic string that + // ends up in `last_error` would read as gibberish in a mail. + await announce.runCancelled(run, note) + // **The teardown is not done here, and the request does not wait for it.** // Cleanup is one leg of the runner's tick over terminal runs (§L), which is // what makes it survive a process that dies halfway through it — and a cancel diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js index 6abb204..19486a1 100644 --- a/server/src/model/events/eventRunLog.db.js +++ b/server/src/model/events/eventRunLog.db.js @@ -57,6 +57,14 @@ const KINDS = [ 'cleanup.failed', // a group did not, with the reason and how it was left 'cleanup.swept', // one pass over a run's ledger, and what it found 'cleanup.retry', // a human cleared the attempt counter and asked again + // Phase 10's four: the integrations. `announcement.emitted` is a line about + // what the run SAID happened, not about who was told -- the engagement engine + // owns that decision and logs its own, and a run log that claimed to know how + // many mails went out would be reporting a decision it does not make. + 'participants.recorded', // a step reported who took part, and they are recorded + 'results.published', // the results table was ranked and stamped + 'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling + 'announcement.enqueued', // a post was linked to this run and queued on the legs ] const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) } diff --git a/server/src/model/events/eventRunParticipants.db.js b/server/src/model/events/eventRunParticipants.db.js new file mode 100644 index 0000000..4c54f88 --- /dev/null +++ b/server/src/model/events/eventRunParticipants.db.js @@ -0,0 +1,119 @@ +// ── event_run_participants — SQL only ────────────────────────────────────── +// +// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The eleventh and last of +// §D's core tables: who took part in a run, and how well. +// +// **Core writes this table and never sources it.** A `member_key` is +// module-opaque, exactly as a resource's `ref` is — core cannot map a character +// name onto a user row and must not try, because that mapping is one game's +// (`shard_links`, for module-uo) and would be that game compiled into core. A +// module that knows both halves reports both; core stores what it is told. +// +// **Every write is an upsert on `(run_id, member_key)`.** A module's collect step +// can be retried — that is what `EVENT_STEP_MAX_ATTEMPTS` means — and a retried +// collect that duplicated its rows would double a leaderboard. It is the same +// argument `materialisePhase`'s `INSERT IGNORE` makes about steps, one table +// along, with the difference that a re-report may carry a BETTER score and must +// win rather than be ignored. + +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +const COLUMNS = `id, run_id, member_key, user_id, score, rank_at, joined_at, meta, + created_at, updated_at` + +// `score` is `DECIMAL(18,4)` and the pool sets `decimalAsNumber`, so it already +// arrives as a JS number; the coercion is belt to that braces and costs nothing. +// `meta` is hydrated for the reason a resource's payload is: opaque to core, but +// every caller wants the object rather than the string the driver returns. +const hydrate = (row) => + row && { ...row, score: Number(row.score), meta: parseJson(row.meta, null) } + +/** + * Record one participant, or update the one already recorded. + * + * **`joined_at` is written on INSERT and never on UPDATE**, and that asymmetry is + * the point of the column: it is when this participant first appeared, and a + * second report — a later collect, a corrected score — must not rewrite it. The + * same applies to `rank_at`, which is not touched here at all: ranking is + * `core.results.publish`'s job and a re-report between two publications must not + * silently invent a rank nobody computed. + * + * `user_id` DOES move on a re-report, deliberately: a player who linked their + * website account between two collects should stop being anonymous, and the + * module is the only thing that can know they did. + * + * **It answers nothing, and the reason is a trap worth naming.** The obvious + * return is "was this new", read off `affectedRows` — 1 for an insert, 2 for an + * update. That is true only without `CLIENT_FOUND_ROWS`, and this connector + * sends it: with it, a re-report whose values are identical also answers 1, so + * the flag would report every idempotent retry as a fresh participant. The + * caller wants "how many were reported" anyway, which it already knows from the + * length of its own list. + */ +async function record({ runId, memberKey, userId = null, score = 0, meta = null, joinedAt = null }) { + await query( + `INSERT INTO event_run_participants (run_id, member_key, user_id, score, meta, joined_at) + VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP)) + ON DUPLICATE KEY UPDATE + user_id = VALUES(user_id), + score = VALUES(score), + meta = VALUES(meta)`, + [runId, memberKey, userId, score, meta === null ? null : JSON.stringify(meta), joinedAt], + ) +} + +/** One run's participants, best first. The results table, and the console's. */ +async function listForRun(runId, limit = 500) { + const rows = await query( + `SELECT ${COLUMNS} FROM event_run_participants + WHERE run_id = ? + ORDER BY score DESC, joined_at ASC, id ASC + LIMIT ?`, + [runId, limit], + ) + return rows.map(hydrate) +} + +/** How many the run has. Its own query because the trigger payload needs only this. */ +async function countForRun(runId) { + const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId]) + return Number(rows[0]?.n || 0) +} + +/** + * Number every participant of one run by score, best first. + * + * **One statement, and it has to be one.** The obvious form — `SET @rk := 0` + * followed by an `UPDATE … SET rank_at = (@rk := @rk + 1) ORDER BY …` — is + * wrong here in a way that would have passed every test that did not run twice + * concurrently: `query()` takes a connection from the pool per call and releases + * it, so the session variable is set on one connection and read on whichever the + * second call happens to get. A window function needs no session state at all. + * + * **The ordering is total.** `score DESC` alone leaves ties in whatever order the + * engine felt like, so two publications of the same run would hand out different + * ranks to the same two people; `joined_at` then `id` breaks every tie the same + * way every time, which is what makes re-publishing idempotent rather than a + * reshuffle. + * + * Ties share nothing — two people on the same score get consecutive ranks rather + * than a dense or competition ranking. That is a presentation decision belonging + * to whatever renders the table; what this owes is a stable number. + */ +async function rankRun(runId) { + const result = await query( + `UPDATE event_run_participants p + JOIN (SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, joined_at ASC, id ASC) AS rk + FROM event_run_participants + WHERE run_id = ?) r ON r.id = p.id + SET p.rank_at = r.rk`, + [runId], + ) + // The connector sends CLIENT_FOUND_ROWS, so this counts rows MATCHED rather + // than rows changed — which is the number wanted here. Re-publishing a run + // whose ranks are already correct answers "12 ranked", not "0". + return Number(result.affectedRows || 0) +} + +module.exports = { record, listForRun, countForRun, rankRun } diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js index 5b224ff..86659c8 100644 --- a/server/src/model/events/eventRuns.db.js +++ b/server/src/model/events/eventRuns.db.js @@ -412,6 +412,20 @@ async function setCleanupStatus(id, to, from = null) { return Number(result?.affectedRows || 0) === 1 } +/** + * Stamp this run's results table as published (EVENTS.md §J, Phase 10). + * + * **Unguarded, and re-stampable.** `core.results.publish` is an ordinary step + * that an author may place more than once — before an announcement and again + * after a late correction — and each publication is a real one whose moment is + * worth recording. Guarding it on `IS NULL` would make the second silently do + * nothing while the ranking beside it did move, which is the worst of both. + */ +async function markResultsPublished(id, at = new Date()) { + const result = await query('UPDATE event_runs SET results_published_at = ? WHERE id = ?', [at, id]) + return Number(result?.affectedRows || 0) === 1 +} + /** * Runs whose start instant passed more than their own grace window ago (§E, §L). * @@ -515,6 +529,7 @@ module.exports = { transition, setHealth, setCleanupStatus, + markResultsPublished, concurrencyHolder, reclaimStale, terminalBefore, diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js index dd00149..9a11371 100644 --- a/server/src/model/events/eventRuns.model.js +++ b/server/src/model/events/eventRuns.model.js @@ -28,6 +28,7 @@ const versionsDb = require('./eventVersions.db') const settingsDb = require('./eventActionSettings.db') const budgetDb = require('./eventRunBudget.db') const resourcesDb = require('./eventRunResources.db') +const participantsDb = require('./eventRunParticipants.db') const authorize = require('../../events/authorize') const MAX_SCOPE = 190 @@ -205,12 +206,13 @@ async function create( async function detail(runId) { const run = await db.getById(runId) if (!run) return null - const [steps, counts, gateRows, budget, resources] = await Promise.all([ + const [steps, counts, gateRows, budget, resources, attendees] = await Promise.all([ stepsDb.listForRun(runId), stepsDb.statusCounts(runId), gatesDb.listForRun(runId), budgetDb.forRun(runId), resourcesDb.forRun(runId), + participantsDb.listForRun(runId), ]) const now = new Date() return { @@ -259,6 +261,27 @@ async function detail(runId) { // than over the list above — a placeholder left standing by a lost // acknowledgement is exactly the case `cleanup_status` must not call clean. unresolvedResources: resources.filter((r) => resourcesDb.UNRESOLVED.includes(r.status)).length, + // Who took part, best first (Phase 10). Returned on every run rather than + // only on a published one: the console's question is "what did this event + // record", and a run whose module has collected but whose author never + // placed a publish step is exactly the case an operator needs to see. What + // `results_published_at` on the run row then says is whether anyone OUTSIDE + // this screen may read it — which is Phase 14's question, not this one's. + // + // **`rank` is `rank_at`, renamed at the boundary and not in the column.** + // `rank` is a reserved word in MariaDB 10.2+ (it is the window function), + // so the column carries the suffix and the API carries the name a client + // wants. The alternative — backticking the column at every use — is one + // forgotten pair of backticks away from a syntax error in a query nobody + // runs until a run completes at four in the morning. + participants: attendees.map((p) => ({ + memberKey: p.member_key, + userId: p.user_id, + score: p.score, + rank: p.rank_at, + joinedAt: p.joined_at, + meta: p.meta, + })), } } diff --git a/server/src/model/posts/posts.db.js b/server/src/model/posts/posts.db.js index 6e6b499..adf9213 100644 --- a/server/src/model/posts/posts.db.js +++ b/server/src/model/posts/posts.db.js @@ -12,6 +12,19 @@ async function listPublished(category) { ) } +// Every published post, across categories, newest first — the option source +// behind `core.announce.post`'s `postId` param (EVENTS.md §F, Phase 10). Its own +// query rather than a loop over `listPublished` because an authoring dropdown +// wants one bounded, ordered list and needs neither the body nor the excerpt: a +// hundred posts' bodies would be a megabyte of HTML sent to draw a `` — is exercised too + // rather than only the raw `resolve()`. + const answer = await registries.resolveOptionSource('core.options.posts') + assert.equal(answer.ok, true) + assert.deepEqual(answer.options, [ + { value: '1', label: 'The Yew Invasion', group: 'news' }, + { value: '3', label: 'Never announced', group: 'newsletter' }, + ]) + } finally { + postsDb.listPublishedForOptions = saved + } +}) + +// ── the seeded rules ─────────────────────────────────────────────────────── + +const declared = (id) => TRIGGERS.find((t) => t.id === id) +const seededKeys = new Set(templateSeeds.SEEDS.map((s) => s.key)) + +test('two rules are seeded, both for triggers core declares', () => { + assert.equal(coreRules.EVENT_RULES.length, 2) + for (const rule of coreRules.EVENT_RULES) { + assert.ok(declared(rule.trigger_id), `${rule.trigger_id} is not declared`) + } +}) + +test('every seeded rule names templates that exist', () => { + // A rule pointing at a template that does not exist fails on the first firing + // after an operator switches it on, which is the worst moment to find out. + for (const rule of coreRules.EVENT_RULES) { + for (const [channel, key] of Object.entries(rule.template_keys)) { + assert.ok(seededKeys.has(key), `${rule.trigger_id}.${channel} names "${key}", which is not seeded`) + } + } +}) + +test('no seeded rule asks for an audience its trigger\'s ceiling forbids', () => { + for (const rule of coreRules.EVENT_RULES) { + const trigger = declared(rule.trigger_id) + assert.ok( + ceilings.permits(trigger.ceiling, rule.audience), + `${rule.trigger_id} is ceilinged ${trigger.ceiling} and the seeded rule asks for ${rule.audience}`, + ) + } +}) + +test('the failure rule stays at admin and carries no push', () => { + const failed = coreRules.EVENT_RULES.find((r) => r.trigger_id === 'event.run.failed') + assert.equal(failed.audience, 'admin') + // An admin's phone buzzing at four in the morning for a step that will still + // be failed at breakfast is a notification people switch off wholesale — and + // switching it off wholesale is how the one that mattered is missed. + assert.ok(!failed.channels.includes('push')) + // No cooldown, and it is the one rule here that must not have one: the subject + // is the run, so a cooldown would only ever suppress a second failure of the + // very run an administrator most needs the second line about. + assert.equal(failed.cooldown_seconds, 0) +}) + +test('every seeded rule carries an hourly ceiling — a module may choose the number, not decline one', () => { + for (const rule of coreRules.EVENT_RULES) { + assert.ok(Number.isInteger(rule.max_sends_per_hour) && rule.max_sends_per_hour > 0, rule.trigger_id) + } +}) + +test('the event rules have their OWN one-shot key, so an upgraded deployment still gets them', () => { + // The Team key is stamped on every deployment that has booted since Phase 6 + // and the news key on every one since Phase 11. Appending to either list would + // seed these on fresh installs only, and on exactly the upgrades that want + // them, never. + const keys = [coreRules.SEEDED_KEY, coreRules.NEWS_SEEDED_KEY, coreRules.EVENT_SEEDED_KEY] + assert.equal(new Set(keys).size, 3) +}) diff --git a/server/test/eventParticipants.test.js b/server/test/eventParticipants.test.js new file mode 100644 index 0000000..e47f635 --- /dev/null +++ b/server/test/eventParticipants.test.js @@ -0,0 +1,193 @@ +// ── Who took part (EVENTS_PLAN.md Phase 10) ──────────────────────────────── +// +// The write half of `event_run_participants`, and it is `events/ledger.js`'s +// twin in every respect that matters — same envelope, same two success shapes, +// same fail-open-on-a-bad-entry posture. The properties worth a test are the +// ones where getting it wrong is silent: +// +// • a module's bad entry is DROPPED, never a retry — a retried step is a +// re-dispatched world write, which is a far worse outcome than one missing +// name on a leaderboard +// • a `userId` is checked rather than coerced, because the column is a foreign +// key into `users` and a character serial that happened to be a real user id +// would attribute somebody's attendance to a stranger +// • one step may not report the same member twice, and the duplicate is NAMED +// rather than silently letting the last one win +// • the classifier carries `participants` on both success shapes, including +// `await: 'human'` — a cue's confirm finishes the step without a second +// dispatch, so that is the only moment its participants can be recorded + +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const participants = require('../src/events/participants') +const participantsDb = require('../src/model/events/eventRunParticipants.db') +const { classify } = require('../src/events/dispatch') +const db = require('../src/utils/db') + +after(() => db.close()) + +const ACTION = { id: 'uo.participants.collect' } +const RUN = { id: 42 } +const STEP = { id: 7, phase: 'main' } + +let written +const original = participantsDb.record + +beforeEach(() => { + written = [] + participantsDb.record = async (row) => { + written.push(row) + } +}) + +after(() => { + participantsDb.record = original +}) + +// ── normalise ────────────────────────────────────────────────────────────── + +test('a member key is required and is the only required field', () => { + const ok = participants.normalise({ memberKey: 'darrow' }, ACTION.id) + assert.equal(ok.ok, true) + assert.equal(ok.row.memberKey, 'darrow') + assert.equal(ok.row.userId, null) + assert.equal(ok.row.score, 0) + assert.equal(ok.row.meta, null) + assert.equal(ok.row.joinedAt, null) +}) + +test('every bad shape is refused, and each names what was wrong', () => { + const bad = [ + [null, /not an object/], + ['darrow', /not an object/], + [[], /not an object/], + [{}, /bad memberKey/], + [{ memberKey: '' }, /bad memberKey/], + [{ memberKey: 'x'.repeat(participants.MAX_MEMBER_KEY + 1) }, /bad memberKey/], + [{ memberKey: 'd', userId: 0 }, /bad userId/], + [{ memberKey: 'd', userId: -3 }, /bad userId/], + [{ memberKey: 'd', userId: 1.5 }, /bad userId/], + [{ memberKey: 'd', userId: '4' }, /bad userId/], + [{ memberKey: 'd', score: 'lots' }, /bad score/], + [{ memberKey: 'd', score: Number.NaN }, /bad score/], + [{ memberKey: 'd', score: Infinity }, /bad score/], + [{ memberKey: 'd', joinedAt: 'yesterday' }, /bad joinedAt/], + ] + for (const [entry, pattern] of bad) { + const parsed = participants.normalise(entry, ACTION.id) + assert.equal(parsed.ok, false, `${JSON.stringify(entry)} should be refused`) + assert.match(parsed.reason, pattern) + } +}) + +test('a userId that is a real integer rides through; a serial-shaped string does not', () => { + assert.equal(participants.normalise({ memberKey: 'd', userId: 12 }, ACTION.id).row.userId, 12) + assert.equal(participants.normalise({ memberKey: 'd', userId: '0x4001' }, ACTION.id).ok, false) +}) + +test('a negative score is legal — a game may score downward', () => { + assert.equal(participants.normalise({ memberKey: 'd', score: -40 }, ACTION.id).row.score, -40) +}) + +test('meta that is not an object is dropped rather than refusing the whole participant', () => { + // Decoration on a row whose identity is already valid. Losing an event's + // attendance over a stray string would be the wrong trade. + const parsed = participants.normalise({ memberKey: 'd', meta: 'warrior' }, ACTION.id) + assert.equal(parsed.ok, true) + assert.equal(parsed.row.meta, null) + assert.deepEqual(participants.normalise({ memberKey: 'd', meta: { c: 'mage' } }, ACTION.id).row.meta, { c: 'mage' }) +}) + +// ── recordAnswer ─────────────────────────────────────────────────────────── + +test('nothing reported is not an error and writes nothing', async () => { + assert.deepEqual(await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: undefined }), { + recorded: 0, + rejected: [], + }) + assert.equal(written.length, 0) +}) + +test('a bad entry never fails the step, and the good ones beside it still land', async () => { + const out = await participants.recordAnswer({ + run: RUN, + step: STEP, + action: ACTION, + participants: [{ memberKey: 'darrow', score: 12 }, { userId: 4 }, { memberKey: 'marisol' }], + }) + assert.equal(out.recorded, 2) + assert.equal(out.rejected.length, 1) + assert.match(out.rejected[0], /bad memberKey/) + assert.deepEqual(written.map((w) => w.memberKey), ['darrow', 'marisol']) +}) + +test('one step reporting the same member twice writes one row and names the duplicate', async () => { + const out = await participants.recordAnswer({ + run: RUN, + step: STEP, + action: ACTION, + participants: [{ memberKey: 'darrow', score: 12 }, { memberKey: 'darrow', score: 99 }], + }) + assert.equal(out.recorded, 1) + assert.match(out.rejected[0], /twice in one step/) + assert.equal(written.length, 1) + assert.equal(written[0].score, 12) +}) + +test('more participants than one step may report is refused WHOLE, not truncated', async () => { + // Half a leaderboard silently cut is worse than none: the table would look + // complete and be wrong, and nothing downstream could tell. + const many = Array.from({ length: participants.MAX_PER_STEP + 1 }, (_, i) => ({ memberKey: `m${i}` })) + const out = await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: many }) + assert.equal(out.recorded, 0) + assert.equal(written.length, 0) + assert.match(out.rejected[0], /more than the/) +}) + +test('a write that throws is recorded as a rejection rather than becoming the step\'s control flow', async () => { + participantsDb.record = async () => { + throw new Error('deadlock found when trying to get lock') + } + const out = await participants.recordAnswer({ + run: RUN, + step: STEP, + action: ACTION, + participants: [{ memberKey: 'darrow' }], + }) + assert.equal(out.recorded, 0) + assert.match(out.rejected[0], /deadlock/) +}) + +test('the run id is bound by the caller and never taken from the entry', async () => { + await participants.recordAnswer({ + run: RUN, + step: STEP, + action: ACTION, + // A module cannot record somebody into another run by saying so. + participants: [{ memberKey: 'darrow', runId: 99999 }], + }) + assert.equal(written[0].runId, RUN.id) +}) + +// ── the classifier carries them ──────────────────────────────────────────── + +test('participants ride back on BOTH success shapes, and default to an empty list', () => { + assert.deepEqual(classify({ ok: true, participants: [{ memberKey: 'd' }] }, 'a').participants, [{ memberKey: 'd' }]) + assert.deepEqual( + classify({ ok: true, await: 'human', participants: [{ memberKey: 'd' }] }, 'a').participants, + [{ memberKey: 'd' }], + ) + assert.deepEqual(classify({ ok: true }, 'a').participants, []) + assert.deepEqual(classify({ ok: true, await: 'human' }, 'a').participants, []) +}) + +test('a FAILED envelope carries no participants at all', () => { + // A step that did not succeed did not observe anybody, and an action that + // reported attendance alongside a refusal is reporting something it cannot + // know. There is no `participants` on a failure classification to read. + assert.equal(classify({ ok: false, participants: [{ memberKey: 'd' }] }, 'a').participants, undefined) +}) diff --git a/server/test/eventRunControls.test.js b/server/test/eventRunControls.test.js index 2ae7bb0..0838fa4 100644 --- a/server/test/eventRunControls.test.js +++ b/server/test/eventRunControls.test.js @@ -40,6 +40,9 @@ const gatesDb = require('../src/model/events/eventPhaseGates.db') // a new leg under a model needs a stub in every file that stubs that layer. const resourcesDb = require('../src/model/events/eventRunResources.db') const eventCleanup = require('../src/events/cleanup') +const definitionsDb = require('../src/model/events/eventDefinitions.db') +const participantsDb = require('../src/model/events/eventRunParticipants.db') +const engagementEmit = require('../src/utils/engagementEmit') const db = require('../src/utils/db') after(() => db.close()) @@ -55,10 +58,24 @@ const originals = [ ['gates', gatesDb, { ...gatesDb }], ['resources', resourcesDb, { ...resourcesDb }], ['cleanup', eventCleanup, { ...eventCleanup }], + // Phase 10: `cancel` now announces, and `events/announce.js` reads the + // definition. Left unstubbed every cancel here would wait out the dead-port + // pool. Stubbed rather than silenced, so the announce path runs for real and + // `store.emits` can assert it fired after the guarded transition and not + // before it. + ['definitions', definitionsDb, { ...definitionsDb }], + ['participants', participantsDb, { ...participantsDb }], + ['emit', engagementEmit, { ...engagementEmit }], ] function installStubs() { - store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), sweeps: [], unresolved: {}, nextStepId: 1, nextGateId: 1 } + store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), sweeps: [], unresolved: {}, emits: [], nextStepId: 1, nextGateId: 1 } + definitionsDb.getById = async (id) => ({ id, title: `definition ${id}`, summary: null, series_name: null, timezone: 'UTC' }) + participantsDb.countForRun = async () => 0 + engagementEmit.emit = (owner, triggerId, envelope) => { + store.emits.push({ owner, triggerId, envelope }) + return { ok: true } + } const snap = (o) => ({ ...o }) runsDb.setCleanupStatus = async (id, to, from = null) => { @@ -591,6 +608,18 @@ test('an empty reason is stored as NULL rather than as an empty string', async ( // ── cancel decides what happens to the world (Phase 8) ───────────────────── +/** The `run.status` line for one run — Phase 10 stopped it being the last one. */ +const statusLine = (id) => store.log.filter((l) => l.runId === id && l.kind === 'run.status').at(-1) + +test('a refused cancel announces nothing at all', async () => { + // The emit is after the guarded transition, so the loser of a race between two + // moderators pressing cancel has already returned a 409 and said nothing. + const id = seedRun({ status: 'completed', steps: [] }) + const refused = await controls.cancel(id, { reason: 'too late' }, ACTOR) + assert.equal(refused.ok, false) + assert.deepEqual(store.emits, []) +}) + test('cancel gives back what the run took, by default and without waiting for it', async () => { // The teardown is the runner cleanup leg over TERMINAL runs, not this request. // Two reasons, and both are why the control answers at once: a cancel pressed @@ -609,7 +638,15 @@ test('cancel gives back what the run took, by default and without waiting for it // Still `pending`, which is what the leg looks for. The run is terminal the // moment this returns, so the very next tick picks its ledger up. assert.equal(runRow(id).cleanup_status, 'pending') - assert.equal(store.log.at(-1).detail.cleanup, true) + // The status line, found by kind rather than by being last: Phase 10 put an + // `announcement.emitted` line after it, because the announcement genuinely + // happens after the guarded transition. + assert.equal(statusLine(id).detail.cleanup, true) + + // …and the run announced its own cancellation, with the operator's reason and + // not the diagnostic string that ends up in `last_error`. + assert.deepEqual(store.emits.map((e) => e.triggerId), ['event.run.cancelled']) + assert.equal(store.emits[0].envelope.data.reason, 'called off') }) test('cancel WITHOUT cleanup is admin-only, even though the route is wider', async () => { @@ -646,8 +683,8 @@ test('cancel without cleanup leaves the world changes up, and says so on the run assert.equal(result.ok, true) assert.equal(result.cleanup, false) assert.equal(runRow(id).cleanup_status, 'incomplete') - assert.equal(store.log.at(-1).detail.cleanup, false) - assert.equal(store.log.at(-1).detail.by, ACTOR) + assert.equal(statusLine(id).detail.cleanup, false) + assert.equal(statusLine(id).detail.by, ACTOR) }) test('a run that recorded nothing is unaffected by either flag', async () => { diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js index e14efa4..05f6909 100644 --- a/server/test/eventRunner.test.js +++ b/server/test/eventRunner.test.js @@ -51,6 +51,8 @@ const budgetDb = require('../src/model/events/eventRunBudget.db') // it is a ten-second wait on the dead port. const resourcesDb = require('../src/model/events/eventRunResources.db') const gates = require('../src/events/gates') +const participantsDb = require('../src/model/events/eventRunParticipants.db') +const engagementEmit = require('../src/utils/engagementEmit') const db = require('../src/utils/db') after(() => db.close()) @@ -63,7 +65,7 @@ const later = (ms) => new Date(T0.getTime() + ms) let store const originals = {} -for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb], ['resourcesDb', resourcesDb]]) { +for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb], ['resourcesDb', resourcesDb], ['participantsDb', participantsDb], ['engagementEmit', engagementEmit]]) { originals[name] = { mod, fns: { ...mod } } } @@ -98,6 +100,33 @@ function installStubs() { // connection timeout. Object.assign(definitionsDb, { findSchedulable: async () => [] }) + // ── The lifecycle announcements (Phase 10) ── + // + // `events/announce.js` reads the definition on every transition, so left + // unstubbed every emit here would wait out the dead-port pool — ten seconds a + // transition, on a file whose whole subject is how many ticks a thing takes. + // Stubbed rather than silenced: the announce path runs for REAL against these, + // which is what lets `emits()` below assert that the wiring is where it should + // be. Two runner tests turn on it, and a third would have caught the wiring + // being on the losing side of a compare-and-set. + definitionsDb.getById = async (id) => ({ + id, + title: `definition ${id}`, + summary: null, + series_name: null, + timezone: 'UTC', + }) + participantsDb.countForRun = async () => 0 + store.participants = [] + participantsDb.record = async (row) => { + store.participants.push(row) + } + store.emits = [] + engagementEmit.emit = (owner, triggerId, envelope) => { + store.emits.push({ owner, triggerId, envelope }) + return { ok: true } + } + // ── The resource ledger (Phase 8) ── // // `reserve` enforces `uq_evres_target` in the stub, because the refusal it @@ -535,6 +564,7 @@ const step = (actionId, params = {}, onFailure = 'skip') => ({ actionId, params, const run = (id) => store.runs.get(id) const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq) const kinds = (id) => store.log.filter((l) => l.runId === id).map((l) => l.kind) +const emits = () => store.emits.map((e) => e.triggerId) const gateOf = (id, phase) => store.gates.get(`${id}|${phase}`) // A registered test action whose behaviour the test dictates. @@ -658,6 +688,103 @@ test('a run passes through `ending` on its way to completed', async () => { assert.deepEqual(transitions, ['starting', 'running', 'ending', 'completed']) }) +// ── The lifecycle announcements (Phase 10) ───────────────────────────────── +// +// §J: a run says what happened and an operator's rule decides who is told. What +// belongs in THIS file is only that the runner says it at the right moments — +// after the guarded transition, once, and not for the phase `run.started` +// already covered. + +test('a run announces its lifecycle: started, each LATER phase, ending, completed', async () => { + register([scriptedAction('test.noop')]) + + const id = seedRun([ + { key: 'opening', label: 'Opening', steps: [step('test.noop')] }, + { key: 'closing', label: 'Closing', steps: [step('test.noop')] }, + ]) + await runner.tick(T0) + + // The FIRST phase does not fire `phase.changed`. `run.started` already said the + // event began, and a deployment with a rule on each would announce the opening + // twice, seconds apart, saying the same thing. + assert.deepEqual(emits(), [ + 'event.run.started', + 'event.phase.changed', + 'event.run.ending', + 'event.run.completed', + ]) + + const changed = store.emits.find((e) => e.triggerId === 'event.phase.changed') + assert.equal(changed.envelope.data.phase, 'closing') + assert.equal(changed.envelope.data.phaseIndex, 2) + assert.equal(changed.envelope.data.phaseCount, 2) + + // Keyed on the run, so a weekly event is not throttled by last week's. + assert.equal(changed.envelope.subject, String(id)) + assert.equal(changed.envelope.scopeKey, `event:${id}`) +}) + +test('a REHEARSAL announces the same things, ceilinged to staff', async () => { + // §I. Emitting nothing would be a rehearsal of everything except the + // announcements, which are the part most worth rehearsing. + register([scriptedAction('test.noop')]) + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }]) + store.runs.get(id).rehearsal = 1 + await runner.tick(T0) + + assert.deepEqual(emits(), ['event.run.started', 'event.run.ending', 'event.run.completed']) + assert.ok(store.emits.every((e) => e.envelope.ceiling === 'staff')) +}) + +test('a failed run announces the failure and nothing else', async () => { + register([scriptedAction('test.boom')]) + scripted['test.boom'] = { calls: [], answer: { ok: false, retry: false, error: 'the shard said no' } } + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.boom', {}, 'abort_run')] }]) + await runner.tick(T0) + + assert.equal(run(id).status, 'failed') + assert.deepEqual(emits(), ['event.run.started', 'event.run.failed']) + const failed = store.emits.find((e) => e.triggerId === 'event.run.failed') + assert.equal(failed.envelope.data.error, 'the shard said no') + assert.equal(failed.envelope.data.runUrl, `/admin/events/runs/${id}`) +}) + +test('a step reporting participants records them, and a bad one does not fail the step', async () => { + register([scriptedAction('test.collect')]) + scripted['test.collect'] = { + calls: [], + answer: { + ok: true, + participants: [{ memberKey: 'darrow', score: 12, userId: 4 }, { score: 3 }], + }, + } + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.collect')] }]) + await runner.tick(T0) + + assert.equal(stepsOf(id)[0].status, 'done') + assert.deepEqual(store.participants.map((p) => p.memberKey), ['darrow']) + assert.equal(store.participants[0].runId, id) + + const line = store.log.find((l) => l.runId === id && l.kind === 'participants.recorded') + assert.equal(line.detail.recorded, 1) + assert.match(line.detail.rejected.join(' '), /bad memberKey/) +}) + +test('a run that completes counts the participants it recorded', async () => { + register([scriptedAction('test.collect')]) + scripted['test.collect'] = { calls: [], answer: { ok: true, participants: [{ memberKey: 'darrow' }] } } + participantsDb.countForRun = async () => 1 + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.collect')] }]) + await runner.tick(T0) + + const completed = store.emits.find((e) => e.triggerId === 'event.run.completed') + assert.equal(completed.envelope.data.participantCount, 1) +}) + test('phases run in order and the next one is materialised on entry', async () => { register([scriptedAction('test.noop')]) diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js index 0b77599..e5706bf 100644 --- a/server/test/eventsAdmin.test.js +++ b/server/test/eventsAdmin.test.js @@ -49,6 +49,7 @@ const budgetDb = require('../src/model/events/eventRunBudget.db') // one missing stub here cost the run detail test ten seconds and said nothing // about the route it was testing. const resourcesDb = require('../src/model/events/eventRunResources.db') +const participantsDb = require('../src/model/events/eventRunParticipants.db') const seriesDb = require('../src/model/events/eventSeries.db') const gatesDb = require('../src/model/events/eventPhaseGates.db') const activity = require('../src/model/activity/activity.model') @@ -71,6 +72,7 @@ for (const [name, mod] of [ ['settingsDb', settingsDb], ['budgetDb', budgetDb], ['resourcesDb', resourcesDb], + ['participantsDb', participantsDb], ['activity', activity], ]) { originals[name] = { mod, fns: { ...mod } } @@ -353,6 +355,13 @@ function installStubs() { resourcesDb.forRun = async (runId) => store.resources.filter((r) => Number(r.run_id) === Number(runId)) + + // Phase 10: a run's detail now carries its participants. Unstubbed this is the + // ten-second dead-port wait, for the sixth time in this feature — and it would + // 500 the run console rather than saying anything about the route. + store.participants = [] + participantsDb.listForRun = async (runId) => + store.participants.filter((p) => Number(p.run_id) === Number(runId)) } // ── Fixtures ─────────────────────────────────────────────────────────────── @@ -427,7 +436,10 @@ test('the catalog serves the registry, callables stripped, with its vocabularies assert.equal(res.statusCode, 200) assert.deepEqual( res.body.actions.map((a) => a.id), - ['core.announce', 'core.wait', 'core.cue', 'core.lease'], + // Phase 10's two are the integrations (EVENTS.md §J): `core.announce.post` + // sends an article through the announce legs rather than a line, and + // `core.results.publish` ranks and stamps the run's participants. + ['core.announce', 'core.wait', 'core.cue', 'core.lease', 'core.announce.post', 'core.results.publish'], ) for (const action of res.body.actions) assert.equal(action.perform, undefined) assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible']) @@ -446,7 +458,7 @@ test('the catalog serves the registry, callables stripped, with its vocabularies // their own. assert.deepEqual( res.body.optionSources.map((s) => s.id), - ['core.options.legs', 'core.options.leases'], + ['core.options.legs', 'core.options.leases', 'core.options.posts'], ) for (const s of res.body.optionSources) assert.equal(s.resolve, undefined) }) @@ -839,7 +851,19 @@ test('the board serves every registered action with its risk-class default, and assert.equal(res.statusCode, 200) const byId = Object.fromEntries(res.body.actions.map((a) => [a.id, a])) - assert.deepEqual(Object.keys(byId).sort(), ['core.announce', 'core.cue', 'core.lease', 'core.wait']) + assert.deepEqual(Object.keys(byId).sort(), [ + 'core.announce', + 'core.announce.post', + 'core.cue', + 'core.lease', + 'core.results.publish', + 'core.wait', + ]) + // `core.results.publish` is `inspect`, so like `core.wait` it arrives ENABLED: + // nothing in the game world changes and nobody is messaged, and an author + // should be able to place it without an admin first visiting this screen. + assert.equal(byId['core.results.publish'].enabled, true) + assert.equal(byId['core.results.publish'].changesWorld, false) // And `core.lease` is the one core action the default-off rule bites: it is // `change`, so a fresh deployment cannot borrow a value until an admin says so. // §K's sentence, applied to core's own verb rather than only to a module's. diff --git a/server/test/moduleRegistries.test.js b/server/test/moduleRegistries.test.js index e659321..969d7fa 100644 --- a/server/test/moduleRegistries.test.js +++ b/server/test/moduleRegistries.test.js @@ -53,12 +53,23 @@ test('registerCore registers exactly what core owns, and nothing else', () => { // supplies who is in a Team, but who may be told about it is the access // resolver's answer. Asserted as an exact list so a shard-content stream // creeping back into core's registration fails here rather than shipping. + // + // **`event.run.started` joined them in Events Phase 10, and it is the one + // event trigger that is also a stream** (org lead, 2026-09-04). A stream is a + // PUSH toggle: `publishToUsers` joins `notification_subscriptions`, which is + // only ever written for an id the preferences screen offered push for, and + // that screen offers push only for a registered stream. So a rule naming + // `push` on a trigger-only id publishes a tickle to nobody while the send log + // records it sent — which is what the live walk found. Push is the channel + // that says *now*, so the one lifecycle moment worth waking a phone for gets + // it and the other six do not. assert.deepEqual(registries.allStreams().map((s) => s.id), [ 'news.post', 'team.member.joined', 'team.leadership.changed', 'team.forum.post', 'team.announcement', + 'event.run.started', ]) assert.deepEqual(registries.announceLegIds(), ['discord']) assert.equal(registries.slotFilledBy('admin.users.detail'), null)