feat(events): a trigger can start a run
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 35s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 12m45s

Rust PLAN_REDESIGNS §14.3.12 (D370), stage 9 of §14.3. A definition's
published version may carry `starts`: a trigger, a condition and a
cooldown. A matching firing creates a run of it, now.

- spec: `starts` validated by the phase gate's own function (same
  grammar, same dormant rule; a dormant start refuses publish), a
  cooldown from 0m to 7d (30m default), at most ten, event.* refused.
- events/starts.js: the emit path's third subscriber, beside dispatch
  and the gate observer. An unverified version starts nothing; one run
  at a time per definition and the cooldown are one conditional UPDATE
  on the new event_trigger_starts table; the occurrence key makes one
  firing reported twice one run. Ignored firings are counted by why.
- event_runs.origin: the firing (trigger, label, time, subject, matched
  condition, declared variables), also on the run.created audit line.
- Editor: a "Started by the game" panel using the gate's condition
  builder, with the ignored-firings line. Run console: "Started because
  ... fired".
- No module contract change and no MODULE_API bump.

Tests: eventStarts (server 21, client 8); eventRunnerSql +7 against a
real MariaDB (75/75); Module-uo proof on uo.champ.boss_up against the
dev database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-16 01:51:55 -05:00
parent eb773693d3
commit c9e992588b
18 changed files with 1352 additions and 20 deletions

View File

@@ -221,6 +221,89 @@ export function whereFormFrom(where) {
}
}
// ── Starting from a trigger (Rust PLAN_REDESIGNS §14.3.12, D370) ───────────
//
// A start is a phase gate's trigger and condition plus a cooldown, so the form
// holds it in the gate's own fields — and the same `WhereBuilder` draws it. The
// server (`events/spec.js`) decides; this only carries the fields.
export const DEFAULT_COOLDOWN = '30m'
// Core's own lifecycle triggers cannot start a run; the editor does not offer
// them, and the server refuses them if posted anyway.
export const isStartableTrigger = (trigger) => !String(trigger?.id || '').startsWith('event.')
export function blankStart() {
return { on: '', cooldown: DEFAULT_COOLDOWN, dormant: false, ...blankWhere() }
}
/** The stored `starts`, as the form's rows. */
export function startsFormFrom(starts) {
return (starts || []).map((s) => ({
...blankStart(),
on: s.on || '',
cooldown: s.cooldown ?? DEFAULT_COOLDOWN,
dormant: Boolean(s.dormant),
...whereFormFrom(s.where),
}))
}
/**
* The form's starts as the spec's list. A row with no trigger chosen is dropped
* rather than posted — it is a half-added row, not a start the author wrote.
*/
export function startsPayload(rows, errors, triggersById = new Map()) {
const out = []
;(rows || []).forEach((row, i) => {
if (!row.on) return
const gate = advancePayload(
{ ...row, kind: 'on' },
`Start ${i + 1}`,
errors,
triggersById.get(row.on)?.variables || [],
)
const entry = { on: row.on, cooldown: String(row.cooldown ?? '').trim() || DEFAULT_COOLDOWN }
if (gate?.where) entry.where = gate.where
out.push(entry)
})
return out
}
/**
* Why a run exists, when a game firing started it — the console's sentence.
* Null for a run started by hand or by the schedule.
*/
export function describeOrigin(origin) {
if (!origin || origin.kind !== 'trigger') return null
const vars = Object.entries(origin.variables || {})
.filter(([k]) => k !== '_truncated')
.map(([k, v]) => `${k} ${typeof v === 'string' ? v : JSON.stringify(v)}`)
const label = origin.label || origin.triggerId
return [
`Started because “${label}” fired`,
origin.matched ? ` (matching ${origin.matched})` : '',
vars.length ? `: ${vars.join(' · ')}` : '',
].join('')
}
/**
* The editor's line about what its trigger starts have done — or null when a
* trigger has never tried. "2 firings ignored while running, 1 in its cooldown".
*/
export function describeTriggerStarts(state) {
if (!state) return null
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
const ignored = [
state.ignoredRunning ? `${plural(state.ignoredRunning, 'firing')} ignored while a run was going` : null,
state.ignoredCooldown ? `${plural(state.ignoredCooldown, 'firing')} ignored in the cooldown` : null,
state.ignoredUnverified
? `${plural(state.ignoredUnverified, 'firing')} ignored because the published version is not verified`
: null,
].filter(Boolean)
const last = state.lastRunId ? `Last started run #${state.lastRunId}` : 'No run started by a trigger yet'
return ignored.length ? `${last}; since then ${ignored.join(', ')}.` : `${last}.`
}
/** The editor's working state, from what `GET /admin/events/:id` returned. */
export function formFromDefinition(event) {
const spec = event?.spec || {}
@@ -243,6 +326,7 @@ export function formFromDefinition(event) {
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
...scheduleFormFrom(spec.schedule),
starts: startsFormFrom(spec.starts),
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
label: p.label || '',
@@ -347,6 +431,8 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
}
})
const starts = startsPayload(form.starts, errors, triggersById)
if (errors.length) return { ok: false, errors }
return {
@@ -363,7 +449,10 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
timezone: form.timezone,
listed: Boolean(form.listed),
listed: Boolean(form.listed),
spec: { schedule: scheduleFromForm(form), phases },
// `starts` only when there is one, as the server stores it (D370).
spec: starts.length
? { schedule: scheduleFromForm(form), starts, phases }
: { schedule: scheduleFromForm(form), phases },
},
}
}
@@ -1012,7 +1101,9 @@ export function describeLogLine(line) {
? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}`
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
case 'run.created':
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}${
d.source === 'trigger' && d.origin ? ` — ${describeOrigin(d.origin)}` : ''
}`
case 'phase.gate':
return d.kind === 'after'
? `${line.phase} advances ${d.after} after it started`