// ── event_run_phase_gates — SQL only ─────────────────────────────────────── // // EVENTS.md §E, and Phase 5 of EVENTS_PLAN.md. A phase used to advance on one // fact — every step terminal — and that fact lives in `event_run_steps`. An // advance CONDITION is a second fact, and it is the only one in this feature // that is not derivable from a row somebody already wrote: `{ on: // 'uo.champ.boss_up', count: 3 }` counts things that happen between one tick and // the next, and the runner is not running when they happen. A gate row is where // a firing is counted at the moment it fires. // // **Two writers, and they are not the same process leg.** The RUNNER opens a // gate (at phase entry) and closes an `after` one (when its deadline passes); // the EMIT PATH increments and closes an `on` one. Everything here is therefore // written as a single guarded statement rather than a read-then-write, which is // the same argument `event_run_budget`'s conditional increment makes one phase // early and the same one `runsDb.transition` makes for a status. // // **Nothing here throws at the emit path.** `observe` is called from inside a // game-event handler by way of `ctx.events.emit`, exactly as `engine.dispatch` // is, and a database problem of core's must not become a module's control flow. // The catch lives in `events/gates.js`; this file is the statements. const { query } = require('../../utils/db') const { parseJson } = require('./eventJson') const hydrate = (row) => row && { ...row, conditions: parseJson(row.conditions, null), last_event: parseJson(row.last_event, null), } /** * Open a phase's gate. **INSERT IGNORE against `uq_evgate_phase`**, so a process * that died between entering a phase and getting here opens no second gate on * the next tick — the idempotence `materialisePhase` has, for the same reason. * * Answers whether a row was created, which is what lets the caller log * `phase.entered`'s gate detail exactly once. */ async function open({ runId, phase, kind, afterSeconds = null, triggerId = null, conditions = null, needed = 1, now = new Date() }) { // `due_at` is computed here, once, from the moment the phase was entered — // never re-derived on a later tick from a `now` that has moved. A deadline // recomputed every fifteen seconds is a deadline that never arrives. const dueAt = kind === 'after' ? new Date(now.getTime() + afterSeconds * 1000) : null const result = await query( `INSERT IGNORE INTO event_run_phase_gates (run_id, phase, kind, after_seconds, trigger_id, conditions, needed, entered_at, due_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ runId, phase, kind, afterSeconds, triggerId, conditions === null ? null : JSON.stringify(conditions), needed, now, dueAt, ], ) return Number(result?.affectedRows || 0) === 1 } /** One run's gate for one phase, or null. */ const forPhase = async (runId, phase) => hydrate( ( await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? AND phase = ? LIMIT 1', [ runId, phase, ]) )[0] || null, ) /** Every gate a run has ever opened, oldest first — what the run console reads. */ const listForRun = async (runId) => ( await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? ORDER BY entered_at, id', [ runId, ]) ).map(hydrate) /** * Every OPEN gate waiting on one trigger, with the run's status and phase. * * This is the emit path's only query and the one index in this feature on a hot * path. The join is what keeps a gate belonging to a cancelled run from counting * for ever: a run that will never advance again must stop tallying, and its * row's `satisfied_at` is not what says so. * * **`paused` counts.** The world does not stop because an operator paused the * console, and discarding firings that arrived during a pause would make pause a * destructive control — the tally an operator came back to would be lower than * the one they left, with nothing recording the difference. */ const openForTrigger = async (triggerId) => ( await query( `SELECT g.* FROM event_run_phase_gates g JOIN event_runs r ON r.id = g.run_id WHERE g.trigger_id = ? AND g.satisfied_at IS NULL AND r.status IN ('running','paused') AND r.current_phase = g.phase LIMIT 200`, [triggerId], ) ).map(hydrate) /** * Count one matching firing, and close the gate if that was the last one needed. * * **One statement, with the threshold inside it.** Two emits arriving together * each add one and exactly one of them crosses `needed`; a read-then-write would * let both see 2 of 3 and neither satisfy, or both satisfy and advance a phase * twice. `WHERE satisfied_at IS NULL` is what makes a late arrival a no-op * rather than a tally that keeps climbing after the phase moved on. * * Answers `{ counted, satisfied }` read back from the row, so the caller logs * the tally the database actually holds rather than the one it predicted. */ async function count(gateId, { lastEvent = null, now = new Date() } = {}) { // **THE INCREMENT MUST BE LAST, and this is not style.** MariaDB evaluates an // UPDATE's SET assignments LEFT TO RIGHT, each one seeing the values already // assigned by the ones before it — which is a documented departure from // standard SQL, and it is invisible in a stub. With `tally = tally + 1` first, // the CASE that follows reads the ALREADY-INCREMENTED tally, so `tally + 1 >= // needed` is really `new + 1 >= needed` and a gate needing two firings closes // on the first. Written this way, both CASEs see the old tally and say exactly // what they read as. `eventRunnerSql.test.js` is what catches a reorder, and // it is what caught this one. const result = await query( `UPDATE event_run_phase_gates SET satisfied_at = CASE WHEN tally + 1 >= needed THEN ? ELSE NULL END, satisfied_by = CASE WHEN tally + 1 >= needed THEN 'condition' ELSE NULL END, last_event = ?, last_event_at = ?, tally = tally + 1 WHERE id = ? AND satisfied_at IS NULL`, [now, lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId], ) if (Number(result?.affectedRows || 0) !== 1) return { counted: false, satisfied: false } const row = await byId(gateId) return { counted: true, satisfied: Boolean(row?.satisfied_at), tally: row?.tally ?? null } } /** * Record that a firing was seen and did NOT match. * * Only `last_event_at` and `last_event` move: the tally is what the phase is * waiting on, and a near miss is not progress. It is recorded at all because * "the boss did spawn, in the wrong region" and "no boss has spawned" are * different answers to the operator's question, and only this column can tell * them apart on a screen. */ async function noteNearMiss(gateId, { lastEvent = null, now = new Date() } = {}) { const result = await query( `UPDATE event_run_phase_gates SET last_event = ?, last_event_at = ? WHERE id = ? AND satisfied_at IS NULL`, [lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId], ) return Number(result?.affectedRows || 0) === 1 } /** * Close a gate for a reason that is not a matching firing: `'elapsed'` when an * `after` deadline passed, `'forced'` when a human pressed advance. * * Guarded on `satisfied_at IS NULL` like everything else here, so a force that * races the tick that would have opened the gate anyway loses harmlessly and the * log records whichever actually happened rather than both. */ async function satisfy(gateId, by, { userId = null, now = new Date() } = {}) { const result = await query( `UPDATE event_run_phase_gates SET satisfied_at = ?, satisfied_by = ?, forced_by = ? WHERE id = ? AND satisfied_at IS NULL`, [now, by, userId, gateId], ) return Number(result?.affectedRows || 0) === 1 } const byId = async (id) => hydrate((await query('SELECT * FROM event_run_phase_gates WHERE id = ? LIMIT 1', [id]))[0] || null) module.exports = { open, forPhase, byId, listForRun, openForTrigger, count, noteNearMiss, satisfy }