// ── Phase advance gates: the emit-path observer, and the words for the panel ─ // // EVENTS.md §E and § Observability, and Phase 5 of EVENTS_PLAN.md. Two things // live here because they are two halves of one claim — that an operator can // answer *"why didn't phase 3 start?"* without reading a server log: // // `observe(event)` — the trigger stream's other subscriber. Beside // `engine.dispatch`, on the same seam, with the same // fire-and-forget posture. // `describe(gate)` — the same gate rendered in the condition builder's own // words, which is what the diagnosis panel shows. // // **Why the counting happens here and not on the runner's tick.** A gate that // waits for three boss spawns is counting things that happen *between* ticks. A // poller cannot count them: fifteen seconds after the third spawn there is // nothing left to observe, and a tally kept in a process's memory is a tally a // restart silently sets back to zero — with the phase then waiting for three // more of something that already happened. So the emit path writes, and the tick // reads. That division is the whole design of this file. // // **The cost of that, and its bound.** Every game event of every trigger some // run is waiting on costs one indexed lookup, and the common answer is zero // rows. Only when a gate is open does anything else happen, and then it is one // UPDATE per open gate — bounded by how many runs can be waiting on one trigger // at once, which is bounded by how many runs exist. // // **The words are rendered here, on the server, not in the client.** The panel's // entire value is that it reads the way the condition builder reads — `gte` as // *"is at least"*, `present` as *"is present"* — and those labels are defined in // `engagement/conditions.js`. A renderer in the browser would be a second // implementation of a grammar the server owns, and the first operator to meet a // clause it spelled differently would be the operator diagnosing a stalled run // at two in the morning. const gatesDb = require('../model/events/eventPhaseGates.db') const runsDb = require('../model/events/eventRuns.db') const logDb = require('../model/events/eventRunLog.db') const conditions = require('../engagement/conditions') const log = require('../utils/logger')('event-gates') // How long an `on` gate may wait before the run is called `stalled` (§E's third // health value, which nothing had ever written before this phase). It is a // VISIBILITY threshold and not a timeout: nothing advances, nothing fails, and // the operator decides. An hour is long enough that a champion spawn nobody has // killed yet is not an alarm, and short enough that a run which will wait for // ever is on the screen inside one shift. // // It does not apply to an `after` gate. A phase waiting out six hours it was // authored to wait is not stalled, it is working, and health that said otherwise // would train an operator to ignore it. const STALL_MS = Number(process.env.EVENT_PHASE_STALL_MS) || 60 * 60 * 1000 /** Every variable a condition tree names, in the order it names them. */ function variablesIn(node, out = []) { if (!node || typeof node !== 'object') return out if (Array.isArray(node.nodes)) { node.nodes.forEach((child) => variablesIn(child, out)) return out } if (node.variable && !out.includes(node.variable)) out.push(node.variable) return out } const quote = (v) => (typeof v === 'string' ? `"${v}"` : String(v)) /** * One condition tree as a sentence, using the grammar's own operator labels. * * `null` answers null rather than "always": the caller renders *"on any * `uo.champ.boss_up`"* for a gate with no predicate, and a phrase saying * "everything is true" would be a clause an operator has to read past. */ function phrase(node) { if (!node || typeof node !== 'object') return null if (node.op === 'not') { const inner = phrase((node.nodes || [])[0]) return inner ? `not (${inner})` : null } if (node.op === 'and' || node.op === 'or') { const parts = (node.nodes || []).map(phrase).filter(Boolean) if (!parts.length) return null // Parenthesised only where it changes the reading. A flat `and` of three // comparisons is a sentence; the same three wrapped in brackets is a // diagnosis an operator has to parse rather than read. const joined = parts.map((p) => (p.includes(' or ') || p.includes(' and ') ? `(${p})` : p)) return joined.join(node.op === 'and' ? ' and ' : ' or ') } const operator = conditions.OPERATORS[node.cmp] if (!operator) return null if (operator.arity === 0) return `${node.variable} ${operator.label}` if (operator.arity === 'list') return `${node.variable} ${operator.label} ${node.value.map(quote).join(', ')}` return `${node.variable} ${operator.label} ${quote(node.value)}` } /** * The shape the run console renders — a gate as an operator reads it. * * Derived rather than stored, every field of it. `stalled` in particular is a * comparison against the clock and not a column: a threshold that had been * written into a row at entry could not be changed by an operator raising * `EVENT_PHASE_STALL_MS`, and one written at the moment of stalling would be a * fourth writer on a row two already share. */ function describe(gate, now = new Date()) { if (!gate) return null const since = new Date(gate.entered_at) const satisfied = Boolean(gate.satisfied_at) // **A satisfied gate's clock stops when it was satisfied**, not at read time. // Live it answers "how long has this phase been waiting"; afterwards it // answers "how long did it wait", and those are the same number only while it // is still waiting. The walk caught it disagreeing with `phase.advanced`'s // own `waitedSeconds` by the age of the screen — 139s beside a logged 121. const until = satisfied ? new Date(gate.satisfied_at) : now const elapsedSeconds = Math.max(0, Math.round((until.getTime() - since.getTime()) / 1000)) return { phase: gate.phase, kind: gate.kind, satisfied, satisfiedAt: gate.satisfied_at || null, satisfiedBy: gate.satisfied_by || null, since: gate.entered_at, elapsedSeconds, // An `after` gate is never stalled; an `on` gate is stalled once it has // waited past the threshold and not before. stalled: !satisfied && gate.kind === 'on' && now.getTime() - since.getTime() >= STALL_MS, ...(gate.kind === 'after' ? { after: gate.after_seconds, dueAt: gate.due_at } : { waitingOn: gate.trigger_id, where: phrase(gate.conditions), seen: gate.tally, needed: gate.needed, lastEvent: gate.last_event, lastEventAt: gate.last_event_at || null, }), } } /** * The trigger stream's second subscriber. * * Called from `engagementEmit.emit` beside `engine.dispatch` and, like it, never * awaited and never allowed to reject. An emit is a module saying something * happened in the game; whether some event run cared is core's business, and a * failure of core's must not become the module's control flow. * * **Every firing is logged, matched or not** (§ Observability: "trigger * evaluations that did and did not satisfy a condition"). The near miss is the * more valuable of the two on the night: *"the boss did spawn, in Britain"* and * *"no boss has spawned"* are different answers, and without this line they look * identical on the screen. */ async function observe(event) { const summary = { gates: 0, counted: 0, satisfied: 0 } try { const open = await gatesDb.openForTrigger(event.triggerId) summary.gates = open.length if (!open.length) return summary const now = new Date() for (const gate of open) { const matched = conditions.evaluate(gate.conditions, event.data || {}) // ONLY the variables the condition names, never the payload. This row is // read back onto an admin screen, and a copy of a whole game event's data // is a second copy of exactly the content `engagement_sends` is careful // not to keep. The named variables are also the useful ones: they are the // reason it did or did not count. const named = variablesIn(gate.conditions) const lastEvent = { trigger: event.triggerId, at: event.occurredAt, subject: event.subject ?? null, matched, variables: Object.fromEntries( named.filter((n) => event.data && n in event.data).map((n) => [n, event.data[n]]), ), } const result = matched ? await gatesDb.count(gate.id, { lastEvent, now }) : { counted: false, satisfied: false, tally: gate.tally, near: await gatesDb.noteNearMiss(gate.id, { lastEvent, now }) } if (matched && result.counted) summary.counted += 1 if (result.satisfied) summary.satisfied += 1 await logDb.write({ runId: gate.run_id, kind: 'condition.evaluated', phase: gate.phase, detail: { trigger: event.triggerId, matched, // The tally the DATABASE holds after the write, not the one this // process predicted — two emits arriving together each read the same // stale number, and only one of them is right about what it became. seen: result.tally ?? gate.tally, needed: gate.needed, satisfied: result.satisfied, variables: lastEvent.variables, }, }) } if (summary.counted || summary.satisfied) { log.info('phase gate advanced', { trigger: event.triggerId, ...summary }) } } catch (err) { log.error('gate observation failed', { trigger: event.triggerId, message: err.message }) } return summary } /** * Is this phase's gate open — and if it is not, why not? * * The runner's question, and the one place an `after` gate is closed: its * deadline passing is not an event anything emits, so the tick that finds it * past `due_at` is what records it. Doing that here rather than in the runner * keeps `satisfied_by` a fact one file writes. * * Answers `{ open, gate }`. `open: true` with a null gate is a phase with no * advance condition at all — every phase before this one, and most after it. */ async function check(runId, phase, now = new Date()) { const gate = await gatesDb.forPhase(runId, phase) if (!gate) return { open: true, gate: null } if (gate.satisfied_at) return { open: true, gate } if (gate.kind === 'after' && gate.due_at && new Date(gate.due_at) <= now) { if (await gatesDb.satisfy(gate.id, 'elapsed', { now })) { return { open: true, gate: await gatesDb.byId(gate.id) } } // Somebody else closed it between the read and the write — a force, or the // tick that overran into this one. Either way it is open, and whichever // reason won is the one in the row. return { open: true, gate: await gatesDb.byId(gate.id) } } return { open: false, gate } } module.exports = { observe, check, describe, phrase, variablesIn, STALL_MS }