feat(events): conditions, phase advancement and the diagnosis panel (Phase 5) #187
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/events-phase-5"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Event System Phase 5 (
EVENTS_PLAN.md). Docs half: RunicGateway/docs#213.One new table,
event_run_phase_gates.engagement/conditions.jsis reused unchanged — its grammar, its type checking against the declaration, its depth and list bounds, and its operator labels.Four decisions, settled 2026-09-02, all as recommended
event_run_logwould have been a JSON predicate no index supports, and it would have made the retention sweep load-bearing for whether a phase advances.stalled. No automatic advance, ever, and no authored timeout: "what should happen when the world did not cooperate" is a decision an operator makes live, not one an author guesses at months earlier. What the engine owes instead is visibility —EVENT_PHASE_STALL_MS(1h) takeshealthto §E's third value, the first thing in this system ever to write it, logged once. It has to be loud, because a held run keeps its concurrency key, so every later occurrence of that definition goesmissedbehind it.after: '30m'means thirty minutes from the moment the phase began whatever its dispatches took, and a firing during the announce counts. Both are what make a gate predictable from the authored spec alone.Three things the build settled
observe()sits besideengine.dispatchinctx.events.emit— a second subscriber rather than a leg of dispatch, because a rules lookup that throws must not lose the count and a gate write that throws must not lose the mail.engagement/conditions.js, and a renderer in the browser would be a second implementation of a grammar the server owns — the first clause the two spelled differently would meet its operator at two in the morning. Only the variables the condition names reach the gate row: it is read back onto an admin screen, and a copy of a whole game event's payload would be a second copy of exactly whatengagement_sendsis careful not to keep.The defect only a real database found, and it was this phase's own statement
The conditional increment was written the obvious way:
which is wrong on MariaDB: an UPDATE's SET assignments are evaluated left to right, each seeing the values already assigned, so the CASE read the incremented tally and a gate needing two firings closed on the first. Every stub agreed with the intent rather than with the server — exactly as engagement's cooldown claim did over
foundRows: true, which is whyeventRunnerSql.test.jsexists at all. The increment now comes last, the order of that SET list is load-bearing, and a test says so.Two things that had to change underneath
setHealthis escalation-only. Health has always been a high-water mark here — nothing has ever cleareddegraded— and Phase 5 gave the column a second writer. Without a rank, a step retrying after a stall would demotestalledback todegraded, and a run that waited ninety minutes on a boss that never came would end its life claiming it merely wobbled./admin/engagement/triggersisadminOnlywhile an event definition is authored byadminandeditor, so pointing the editor at it would have left an editor typing a trigger id from memory into a field the save path then refuses. Each trigger is reduced to id, label and declared variables; a trigger's audience and ceiling are about who gets mailed, which is not this screen's question.Verify
npm test— 1810 tests, 1746 pass, 63 skipped, 1 fail. The one failure isengagementManifest.test.js, pre-existing and environmental (CRLF undercore.autocrlf=true) — confirmed by stashing this branch's changes and watching it fail unchanged.edgebefore this branch is 1768/1711/56/1, and +42 is exactly the tests added; the +7 skipped are the new SQL cases skipping without a database.eventGates.test.js(14) checks the renderer against the grammar's own labels rather than against strings the test wrote down, and covers the observer's near-miss branch and its never-rejects contract. 10 ineventRunner.test.js, 5 ineventRunControls.test.js, 6 ineventSpec.test.js, and 7 ineventRunnerSql.test.jsagainst a real MariaDB (39/39 with a database, skipped without) — one of them the left-to-right defect above. Client: 361 pass, 7 new.npm run routes:manifestandnpm run swagger— one route added, none moved. The client builds.The live walk, on the local review stack
A three-phase Yew Champion Muster — an
ongate needing twouo.champ.boss_upfiringswhere location contains "Yew", then anafter: '10m'gate, then an ungated wind-down — authored, published and started asnavadmin, with firings sent through the realctx.events.emitseam:"regoin" is not a variable of "uo.champ.boss_up","gt" cannot be applied to a string, and1h30mrefused with the grammar spelled out. That is this phase's stated Trap, held on a running server.did not count (location: "Britain (10, 20, 0)")on the panel — and onlylocation, the one variable the condition names.spawnName,bossNameandspawnSerialwere in the payload and never touched the row.aftergate with adueAtten minutes out. Nobody pressed anything.stalledwithEVENT_PHASE_STALL_MS=45000—degradedfirst from a failing announce, then escalated tostalledand logged once, which is the escalation-only guard working live.uo.champ.boss_upwhere location contains "Yew" / seen so far 0 of 2 / since 9:56:58 PM (6 min) / last related eventuo.champ.boss_upat 10:03:05 PM — did not count".navmodpressed Advance phase and got 200; the refusals answered 409 naming what was actually happening — waiting on step 0 (core.announce), not on its advance condition — and a second force said already past its advance condition.Three defects the walk found, all fixed here:
validaterefused its own output. The normalised gate carriesdormant, and the input check did not allow it — so a gated definition saved and then failed to publish over a field the validator itself had written. The rule was already on the page for a step'sactionVersionanddormant; the gate just had to follow it.validate(validate(x)) === validate(x)is now a test.phase.advancedis now written by whoever made the decision, and the tick skipsforced.elapsedSecondsmeasured to read time, so the panel said 139s beside a loggedwaitedSecondsof 121. It now stops atsatisfied_at: live it answers "how long has this been waiting", afterwards "how long did it wait".And one in the harness, for the third time.
runs.detail()gained the gate read, andeventsAdmin.test.jsdoes not stubeventPhaseGates.db— so the run-console test hung ten seconds against the dead-port pool and failed withECONNREFUSED, saying nothing whatever about the route it was testing. Phase 4's expansion leg did the same toeventRunner.test.js, where it only made the file slow. When the runner or a model gains a leg, every file that stubs the layer under it needs the stub — and the symptom is a ten-second test, whether it then fails or merely passes.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