feat(events): a trigger can start a run
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:
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user