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
268 lines
11 KiB
JavaScript
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 })
|
|
})
|