A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.
`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.
One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.
A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
188 lines
8.1 KiB
JavaScript
188 lines
8.1 KiB
JavaScript
// ── 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 }
|