Files
website/server/src/model/events/eventPhaseGates.db.js
wtclaude 9bc0bf5a3d
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s
feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
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
2026-09-02 22:11:20 -05:00

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 }