// ── Phase advance gates (EVENTS_PLAN.md Phase 5) ─────────────────────────── // // The runner's half of this is in `eventRunner.test.js`, where a gate holds a // phase and lets it go. This file is the other half — the two things that only // exist because an operator has to READ them: // // • `phrase()`, which renders a condition tree in the CONDITION BUILDER's own // words. § Observability's claim is that `gte` says "is at least" on the // diagnosis panel because it says "is at least" in the rule editor. That is // a claim about two files agreeing, so it is tested against the grammar's // own labels rather than against a string this file wrote down. // • `observe()`, whose whole reason for existing is that a firing between two // ticks is not observable from either of them — and whose near-miss branch // is the more valuable of its two outcomes on the night. // // **`describe()` is tested for what it does NOT say as much as what it does.** // An `after` gate is never stalled, however long it was authored to wait: a // phase waiting out six hours it was told to wait is working, and health that // said otherwise would train an operator to ignore it. // // Point the DB at a closed port before requiring anything. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, afterEach, after } = require('node:test') const assert = require('node:assert/strict') const gates = require('../src/events/gates') const conditions = require('../src/engagement/conditions') const gatesDb = require('../src/model/events/eventPhaseGates.db') const logDb = require('../src/model/events/eventRunLog.db') const db = require('../src/utils/db') after(() => db.close()) const T0 = new Date('2026-09-02T20:31:04Z') // ── phrase(): the words are the grammar's, not this file's ───────────────── test('every operator renders with the label the condition grammar declares', () => { // Not a table of expected strings: that would be a second copy of the labels, // and the point of rendering server-side is that there is only one. for (const [cmp, operator] of Object.entries(conditions.OPERATORS)) { const leaf = operator.arity === 0 ? { variable: 'region', cmp } : operator.arity === 'list' ? { variable: 'region', cmp, value: ['Yew', 'Britain'] } : { variable: 'region', cmp, value: 'Yew' } const rendered = gates.phrase(leaf) assert.ok(rendered.startsWith(`region ${operator.label}`), `${cmp} should read as "${operator.label}"`) } }) test('a tree reads as a sentence, and only brackets where the reading changes', () => { assert.equal(gates.phrase({ variable: 'region', cmp: 'eq', value: 'Yew' }), 'region is "Yew"') assert.equal( gates.phrase({ op: 'and', nodes: [{ variable: 'region', cmp: 'eq', value: 'Yew' }, { variable: 'level', cmp: 'gte', value: 3 }] }), 'region is "Yew" and level is at least 3', 'a flat and is a sentence, not a nest of brackets', ) assert.equal( gates.phrase({ op: 'or', nodes: [ { op: 'and', nodes: [{ variable: 'a', cmp: 'present' }, { variable: 'b', cmp: 'in', value: ['x', 'y'] }] }, { variable: 'c', cmp: 'absent' }, ], }), '(a is present and b is one of "x", "y") or c is absent', ) assert.equal(gates.phrase({ op: 'not', nodes: [{ variable: 'region', cmp: 'eq', value: 'Yew' }] }), 'not (region is "Yew")') }) test('no conditions renders as nothing, not as "always true"', () => { // The caller says "on any firing of this trigger"; a clause claiming // everything is true is one more thing to read past. assert.equal(gates.phrase(null), null) assert.equal(gates.phrase({ variable: 'x', cmp: 'nonsense', value: 1 }), null) }) test('variablesIn names each variable once, in the order the tree names them', () => { const tree = { op: 'and', nodes: [ { variable: 'region', cmp: 'eq', value: 'Yew' }, { op: 'or', nodes: [{ variable: 'level', cmp: 'gte', value: 3 }, { variable: 'region', cmp: 'ne', value: 'Britain' }] }, ], } assert.deepEqual(gates.variablesIn(tree), ['region', 'level']) assert.deepEqual(gates.variablesIn(null), []) }) // ── describe(): what the panel shows ─────────────────────────────────────── const gateRow = (over = {}) => ({ id: 1, run_id: 1, phase: 'boss', kind: 'on', after_seconds: null, trigger_id: 'uo.champ.boss_up', conditions: { variable: 'region', cmp: 'eq', value: 'Yew' }, needed: 1, tally: 0, entered_at: T0, due_at: null, last_event: null, last_event_at: null, satisfied_at: null, satisfied_by: null, ...over, }) test('the panel answers "why didn\'t phase 3 start?" with the tally, the clock and the clause', () => { const at = new Date(T0.getTime() + 28 * 60_000) const described = gates.describe(gateRow(), at) assert.equal(described.phase, 'boss') assert.equal(described.waitingOn, 'uo.champ.boss_up') assert.equal(described.where, 'region is "Yew"') assert.equal(described.seen, 0) assert.equal(described.needed, 1) assert.equal(described.elapsedSeconds, 28 * 60) assert.equal(described.satisfied, false) assert.equal(described.stalled, false, '28 minutes is not yet a stall') }) test('a satisfied gate stops its clock at the moment it was satisfied', () => { // Live, `elapsedSeconds` 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 found it disagreeing with // `phase.advanced`'s own `waitedSeconds` by the age of the open screen. const gate = gateRow({ satisfied_at: new Date(T0.getTime() + 121_000), satisfied_by: 'forced' }) const muchLater = new Date(T0.getTime() + 3 * 3600_000) assert.equal(gates.describe(gate, muchLater).elapsedSeconds, 121) // And an open one still measures to now. assert.equal(gates.describe(gateRow(), new Date(T0.getTime() + 300_000)).elapsedSeconds, 300) }) test('an `after` gate is never stalled, however long it was told to wait', () => { const gate = gateRow({ kind: 'after', trigger_id: null, conditions: null, after_seconds: 6 * 3600, due_at: new Date(T0.getTime() + 6 * 3600_000), }) const described = gates.describe(gate, new Date(T0.getTime() + gates.STALL_MS * 4)) assert.equal(described.stalled, false) assert.equal(described.after, 6 * 3600) assert.equal(described.waitingOn, undefined, 'and it carries none of the `on` fields') }) test('an `on` gate is stalled once it has waited past the threshold, and never once satisfied', () => { const late = new Date(T0.getTime() + gates.STALL_MS + 1000) assert.equal(gates.describe(gateRow(), late).stalled, true) assert.equal(gates.describe(gateRow({ satisfied_at: T0, satisfied_by: 'condition' }), late).stalled, false) }) // ── observe(): the emit path ─────────────────────────────────────────────── let store function installStubs() { store = { gates: [], log: [] } gatesDb.openForTrigger = async (triggerId) => store.gates.filter((g) => g.trigger_id === triggerId && !g.satisfied_at) gatesDb.count = async (id, { lastEvent, now }) => { const g = store.gates.find((x) => x.id === id) if (!g || g.satisfied_at) return { counted: false, satisfied: false } g.tally += 1 g.last_event = lastEvent g.last_event_at = now if (g.tally >= g.needed) g.satisfied_at = now return { counted: true, satisfied: Boolean(g.satisfied_at), tally: g.tally } } gatesDb.noteNearMiss = async (id, { lastEvent, now }) => { const g = store.gates.find((x) => x.id === id) if (!g) return false g.last_event = lastEvent g.last_event_at = now return true } logDb.write = async (line) => { store.log.push(line) return true } } const originals = [ [gatesDb, { ...gatesDb }], [logDb, { ...logDb }], ] beforeEach(installStubs) afterEach(() => { for (const [mod, fns] of originals) Object.assign(mod, fns) }) const emit = (data, triggerId = 'uo.champ.boss_up') => gates.observe({ triggerId, occurredAt: T0.toISOString(), subject: 'champ:yew', data }) test('a matching firing counts, and a near miss is recorded without counting', async () => { store.gates.push(gateRow({ needed: 2 })) await emit({ region: 'Britain', level: 4 }) assert.equal(store.gates[0].tally, 0) assert.equal(store.gates[0].last_event.matched, false) await emit({ region: 'Yew', level: 4 }) assert.equal(store.gates[0].tally, 1) assert.equal(store.gates[0].satisfied_at, null) await emit({ region: 'Yew', level: 1 }) assert.equal(store.gates[0].tally, 2) assert.ok(store.gates[0].satisfied_at) }) test('only the variables the condition names are recorded, never the payload', async () => { // The 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. store.gates.push(gateRow()) await emit({ region: 'Yew', playerName: 'Dupre', houseLocation: '1423,1712', level: 4 }) assert.deepEqual(store.gates[0].last_event.variables, { region: 'Yew' }) const line = store.log.find((l) => l.kind === 'condition.evaluated') assert.deepEqual(line.detail.variables, { region: 'Yew' }) assert.equal(JSON.stringify(store.log).includes('Dupre'), false) assert.equal(JSON.stringify(store.gates).includes('1423,1712'), false) }) test('both outcomes are logged, with the tally the row actually holds', async () => { store.gates.push(gateRow({ needed: 2 })) await emit({ region: 'Britain' }) await emit({ region: 'Yew' }) const lines = store.log.filter((l) => l.kind === 'condition.evaluated') assert.equal(lines.length, 2) assert.deepEqual(lines.map((l) => l.detail.matched), [false, true]) assert.deepEqual(lines.map((l) => l.detail.seen), [0, 1]) assert.deepEqual(lines.map((l) => l.detail.satisfied), [false, false]) }) test('a gate with no `where` counts every firing of its trigger', async () => { store.gates.push(gateRow({ conditions: null })) await emit({ anything: true }) assert.equal(store.gates[0].tally, 1) assert.deepEqual(store.gates[0].last_event.variables, {}, 'and there are no named variables to record') }) test('a firing of another trigger touches nothing', async () => { store.gates.push(gateRow()) const summary = await emit({ region: 'Yew' }, 'uo.champ.started') assert.equal(summary.gates, 0) assert.equal(store.gates[0].tally, 0) assert.equal(store.log.length, 0) }) test('observe never rejects, however badly the database is behaving', async () => { // It is called from inside a game-event handler by way of `ctx.events.emit`, // exactly as `engine.dispatch` is. A database problem of core's must not // become a module's control flow at three in the morning. gatesDb.openForTrigger = async () => { throw new Error('pool exhausted') } const summary = await emit({ region: 'Yew' }) assert.deepEqual(summary, { gates: 0, counted: 0, satisfied: 0 }) })