The anonymous surface an event was always for: GET /public/events, /public/events/:slug and /public/events/series/:slug, plus GET /player/events/history, and the four screens over them. Four org-lead decisions taken up front: split Phase 14 into 14a (website) and 14b (the app); add a `listed` flag rather than letting `state` mean both schedulable and announced; put the `events` capability string in the version block rather than publishing core as a pseudo-module; and drop "venue" from the spec rather than adding a field nothing had ever built. `listed` is announcement, not permission. Publishing is what makes a definition runnable, so without a separate flag a surprise event would have to be advertised in order to be allowed to happen. It is a column, a switch in Phase 13's editor, and three SQL predicates -- never a filter applied after a read, which works exactly as well until the first caller that forgets. The public shapes are a projection, and the projection is the security boundary: nothing is spread, so a column added to event_runs next year does not ride out through it. The spec, health, cleanup, claims, errors and member_key are all absent by construction. The six public event triggers gained `eventUrl` (version 1 -> 2), carrying ?run= because the page lives at the definition's slug while every trigger is about one occurrence. notify.event-started gained the button, at seedVersion 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
166 lines
7.9 KiB
JavaScript
166 lines
7.9 KiB
JavaScript
// ── 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)
|
|
}
|
|
|
|
/**
|
|
* One account's participation history, most recent event first (Phase 14a).
|
|
*
|
|
* **Joined all the way out to the definition, and the join is the access
|
|
* control.** A rehearsal is excluded by §D's own rule, and an unlisted
|
|
* definition is excluded because unlisting is what an operator does to an event
|
|
* they are not announcing — a history that named it would announce it to
|
|
* everyone who attended, which is everyone who could tell anybody.
|
|
*
|
|
* `member_key` is NOT selected. It is the game's identifier for a character and
|
|
* the caller is a player reading their own page; the run, the date, the score
|
|
* and the rank are what a history is, and the key adds a module-opaque string
|
|
* nothing on the page can render.
|
|
*
|
|
* `rank_at` is null until results are published, and that is a real state the
|
|
* screen shows rather than an error — a run whose participants are collected
|
|
* and unranked is exactly what Phase 10 made visible on the admin side.
|
|
*/
|
|
async function listForUser(userId, { limit = 50, before = null } = {}) {
|
|
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
|
const args = [userId]
|
|
// A keyset cursor on the participation row rather than an offset: the list
|
|
// gains a row every time the reader attends something, and an offset page two
|
|
// would skip whatever arrived in between.
|
|
const cursor = before ? ' AND p.id < ?' : ''
|
|
if (before) args.push(before)
|
|
const rows = await query(
|
|
`SELECT p.id, p.run_id, p.score, p.rank_at, p.joined_at, p.meta,
|
|
r.scheduled_for, r.started_at, r.ended_at, r.status, r.scope,
|
|
r.timezone, r.results_published_at,
|
|
d.title AS definition_title, d.slug AS definition_slug,
|
|
s.name AS series_name, s.slug AS series_slug
|
|
FROM event_run_participants p
|
|
JOIN event_runs r ON r.id = p.run_id
|
|
JOIN event_definitions d ON d.id = r.definition_id
|
|
LEFT JOIN event_series s ON s.id = d.series_id
|
|
WHERE p.user_id = ?${cursor}
|
|
AND r.rehearsal = 0
|
|
AND d.listed = 1
|
|
ORDER BY p.id DESC
|
|
LIMIT ${n}`,
|
|
args,
|
|
)
|
|
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, listForUser, countForRun, rankRun }
|