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
This commit is contained in:
@@ -2349,3 +2349,64 @@ CREATE TABLE IF NOT EXISTS event_run_log (
|
||||
-- scan of every line this deployment has ever logged.
|
||||
INDEX idx_evlog_at (at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- What a phase is waiting for, and how far it has got (§E, Phase 5).
|
||||
--
|
||||
-- A phase used to advance on one fact — every step terminal — and that fact
|
||||
-- lives in `event_run_steps`. An advance CONDITION is a second fact, and it is
|
||||
-- not derivable from any row that already exists: `{ on: 'uo.champ.boss_up',
|
||||
-- count: 3 }` is a tally of things that happened between one tick and the next,
|
||||
-- and the runner is not running when they happen. This table is where a firing
|
||||
-- is counted at the moment it fires.
|
||||
--
|
||||
-- **One row per (run, phase), created at phase entry by INSERT IGNORE**, the
|
||||
-- same idempotence `materialisePhase` has and for the same reason: a process
|
||||
-- that died between entering a phase and writing this must not open a second
|
||||
-- gate on the next tick.
|
||||
--
|
||||
-- **The tally is incremented by one statement with the threshold in it**, never
|
||||
-- read-then-written — the argument `event_run_budget`'s conditional increment
|
||||
-- makes, one phase early. Two emits arriving together each add one, and exactly
|
||||
-- one of them crosses `needed`.
|
||||
--
|
||||
-- `last_event` holds ONLY the variables the condition names, not the payload.
|
||||
-- It exists to answer "what did the last one look like, and why did it not
|
||||
-- count", 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.
|
||||
--
|
||||
-- `satisfied_by` is a VARCHAR rather than an ENUM for `event_run_log.kind`'s
|
||||
-- reason: the set can grow (an authored timeout was considered and declined for
|
||||
-- Phase 5) and this project has no migration system for a column alter. `kind`
|
||||
-- IS an ENUM, because §E closes it at two shapes.
|
||||
CREATE TABLE IF NOT EXISTS event_run_phase_gates (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
phase VARCHAR(64) NOT NULL,
|
||||
kind ENUM('after','on') NOT NULL,
|
||||
-- kind='after': normalised to seconds at save, so the runner never parses a
|
||||
-- duration string. `due_at` is entered_at + this, computed once at entry.
|
||||
after_seconds INT NULL,
|
||||
-- kind='on': the trigger being waited on and the predicate over its declared
|
||||
-- variables. `conditions` is NULL for "any firing of this trigger".
|
||||
trigger_id VARCHAR(96) NULL,
|
||||
conditions JSON NULL,
|
||||
needed INT NOT NULL DEFAULT 1,
|
||||
tally INT NOT NULL DEFAULT 0,
|
||||
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
due_at DATETIME NULL,
|
||||
last_event JSON NULL,
|
||||
last_event_at DATETIME NULL,
|
||||
satisfied_at DATETIME NULL,
|
||||
satisfied_by VARCHAR(16) NULL, -- 'condition' | 'elapsed' | 'forced'
|
||||
forced_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evgate_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evgate_user FOREIGN KEY (forced_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Entry is INSERT IGNORE against this.
|
||||
UNIQUE KEY uq_evgate_phase (run_id, phase),
|
||||
-- The emit path's only query: every open gate waiting on this trigger. It runs
|
||||
-- on every game event of every trigger anything waits on, so it is the one
|
||||
-- index in this feature that is on a hot path rather than an admin screen.
|
||||
INDEX idx_evgate_open (trigger_id, satisfied_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
Reference in New Issue
Block a user