Files
website/server/src/events/gates.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

245 lines
11 KiB
JavaScript

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