Files
website/server/test/eventGates.test.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

268 lines
11 KiB
JavaScript

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