// ── 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 }