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`

View File

@@ -35,6 +35,9 @@ import {
missingParams,
guardedAdvance,
shortfallSentence,
blankStart,
isStartableTrigger,
describeTriggerStarts,
} from '../../../lib/eventAuthoring.js'
import { operatorsForType, coerceLiteral } from '../../../lib/engagementRules.js'
@@ -841,7 +844,7 @@ function TemplatePicker({ templates, sources, loadSource, onDraft }) {
* into `A and B and C` changes which firings release the phase, and an author
* would have no way to know the save had done it.
*/
function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
function WhereBuilder({ advance, trigger, operators, disabled, onChange, emptyText }) {
const variables = trigger?.variables || []
const rows = advance.whereRows || []
@@ -873,8 +876,7 @@ function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
<span className="field-label">Only when</span>
{rows.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '4px 0 0' }}>
Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for
firings that match.
{emptyText || 'Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for firings that match.'}
</p>
)}
{rows.length > 1 && (
@@ -1897,6 +1899,85 @@ export default function EventEditor() {
</p>
</div>
{/* ── Started by the game (Rust PLAN_REDESIGNS §14.3.12, D370) ──
A start is a phase gate's trigger and condition plus a cooldown, so it
is drawn with the gate's own builder. Beside the schedule rather than
inside it: a definition may have either, or both. */}
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Started by the game</h3>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
Start a run when something happens in the game — <em>when a boss appears on a chosen server</em>.
One run at a time: a firing while a run is going starts nothing, and nor does one inside the
cooldown after the last run a trigger started. Like a schedule, it only starts a published
version that has passed its dry run.
</p>
{(form.starts || []).map((start, si) => {
const setStart = (patch) =>
set({ starts: form.starts.map((s, i) => (i === si ? { ...s, ...patch } : s)) })
return (
<div key={si} style={{ borderTop: si ? '1px solid var(--rule, #2a2f3a)' : 'none', paddingTop: si ? 10 : 0, marginTop: si ? 10 : 0 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 260px' }}>
<span className="field-label">When</span>
{/* Changing the trigger clears the condition, as a gate's does. */}
<select className="input" value={start.on} disabled={archived}
onChange={(e) => setStart({ on: e.target.value, dormant: false, ...blankWhere() })}>
<option value="">Choose a trigger…</option>
{start.on && !triggerById.has(start.on) && (
<option value={start.on}>{start.on} — no module registers it</option>
)}
{triggers.filter(isStartableTrigger).map((t) => (
<option key={t.id} value={t.id}>{t.label} — {t.id}</option>
))}
</select>
</label>
<label style={{ flex: '0 1 140px' }}>
<span className="field-label">Cooldown</span>
<input className="input" value={start.cooldown} disabled={archived} placeholder="30m"
onChange={(e) => setStart({ cooldown: e.target.value })} />
</label>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={archived}
onClick={() => set({ starts: form.starts.filter((_, i) => i !== si) })}>
Remove
</button>
</div>
{start.on && !triggerById.has(start.on) && (
<p className="sans" style={{ fontSize: '0.76rem', color: '#d9c184', margin: '6px 0 0' }}>
No installed module registers <code>{start.on}</code>. The start is kept, and the
definition cannot be published until the module is back or the start is removed.
</p>
)}
{start.on && (
<WhereBuilder
advance={start}
trigger={triggerById.get(start.on)}
operators={catalog?.operators || []}
disabled={archived}
emptyText="Every firing of this trigger starts a run. Add a clause to narrow it — only firings that match then start one."
onChange={(patch) => setStart(patch)}
/>
)}
</div>
)
})}
<button type="button" className="pill" style={{ fontSize: '0.74rem', marginTop: 10 }}
disabled={archived || (form.starts || []).length >= 10}
onClick={() => set({ starts: [...(form.starts || []), blankStart()] })}>
Add a trigger
</button>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
A cooldown is a whole number of <code>s</code>, <code>m</code>, <code>h</code> or <code>d</code>,
from <code>0m</code> to <code>7d</code>. Zero leaves one run at a time as the only limit.
</p>
{describeTriggerStarts(event?.triggerStarts) && (
<p className="sans" style={{ fontSize: '0.8rem', margin: '8px 0 0' }}>
{describeTriggerStarts(event.triggerStarts)}
</p>
)}
</div>
{/* ── The live cap meter (Phase 13) ──
Above the timeline rather than under it, because what a plan draws is a
fact about the whole plan and an author scrolling twelve steps to find

View File

@@ -10,6 +10,7 @@ import {
stepControlsFor,
describeLogLine,
describeParams,
describeOrigin,
} from '../../../lib/eventAuthoring.js'
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
@@ -294,6 +295,13 @@ export default function EventRun() {
{run.rehearsal ? ' · rehearsal' : ''}
{run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}
</p>
{/* Why it exists, when a game firing started it (D370). */}
{describeOrigin(run.origin) && (
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
{describeOrigin(run.origin)}
<span className="dim"> · {when(run.origin.occurredAt)}</span>
</p>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div className="sans" style={{ fontSize: '1rem', color: STATUS_COLOR[run.status] || undefined }}>

View File

@@ -0,0 +1,110 @@
// A definition's trigger starts in the editor, and a run's origin on the
// console (Rust PLAN_REDESIGNS §14.3.12, D370). The server decides; these prove
// the form carries what it is given and the sentences read right.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
formFromDefinition,
payloadFromForm,
blankStart,
startsFormFrom,
startsPayload,
isStartableTrigger,
describeOrigin,
describeTriggerStarts,
describeLogLine,
} from '../src/lib/eventAuthoring.js'
const TRIGGER = {
id: 'rust.world.bradley_destroyed',
label: 'Bradley destroyed',
variables: [
{ name: 'server', type: 'string' },
{ name: 'kills', type: 'int' },
],
}
const triggersById = new Map([[TRIGGER.id, TRIGGER]])
const definition = (starts) => ({
title: 'Launch Site Lockdown',
spec: {
schedule: { kind: 'manual' },
...(starts ? { starts } : {}),
phases: [{ key: 'main', label: 'Main', steps: [] }],
},
})
test('a stored start round-trips through the form unchanged', () => {
const starts = [{ on: TRIGGER.id, where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '2h' }]
const form = formFromDefinition(definition(starts))
assert.equal(form.starts.length, 1)
assert.equal(form.starts[0].whereRows.length, 1)
const built = payloadFromForm(form, { triggersById })
assert.equal(built.ok, true)
assert.deepEqual(built.payload.spec.starts, starts)
})
test('no starts is no key, as the server stores it', () => {
const built = payloadFromForm(formFromDefinition(definition()), { triggersById })
assert.equal('starts' in built.payload.spec, false)
assert.deepEqual(formFromDefinition(definition()).starts, [])
})
test('a half-added row is dropped, a blank cooldown is the default, a literal takes the declared type', () => {
const rows = [
blankStart(),
{ ...blankStart(), on: TRIGGER.id, cooldown: ' ', whereRows: [{ variable: 'kills', cmp: 'gte', value: '2' }] },
]
const errors = []
const out = startsPayload(rows, errors, triggersById)
assert.deepEqual(errors, [])
assert.deepEqual(out, [{ on: TRIGGER.id, cooldown: '30m', where: { variable: 'kills', cmp: 'gte', value: 2 } }])
})
test('a nested condition the builder cannot draw is posted back verbatim', () => {
const where = { op: 'and', nodes: [{ variable: 'server', cmp: 'eq', value: 'main' }, { op: 'or', nodes: [{ variable: 'kills', cmp: 'gt', value: 1 }, { variable: 'kills', cmp: 'lt', value: 0 }] }] }
const [row] = startsFormFrom([{ on: TRIGGER.id, where, cooldown: '0m' }])
assert.equal(row.whereEditable, false)
assert.deepEqual(startsPayload([row], [], triggersById), [{ on: TRIGGER.id, cooldown: '0m', where }])
})
test('core\'s own lifecycle triggers are not offered', () => {
assert.equal(isStartableTrigger({ id: 'event.run.started' }), false)
assert.equal(isStartableTrigger(TRIGGER), true)
})
test('the origin sentence', () => {
assert.equal(describeOrigin(null), null)
assert.equal(
describeOrigin({
kind: 'trigger',
triggerId: TRIGGER.id,
label: 'Bradley destroyed',
matched: 'server is "main"',
variables: { server: 'main', grid: 'G8', _truncated: true },
}),
'Started because “Bradley destroyed” fired (matching server is "main"): server main · grid G8',
)
assert.equal(describeOrigin({ kind: 'trigger', triggerId: 'x.y', variables: {} }), 'Started because “x.y” fired')
})
test('the editor\'s ignored-firings line', () => {
assert.equal(describeTriggerStarts(null), null)
assert.equal(
describeTriggerStarts({ lastRunId: 12, ignoredRunning: 2, ignoredCooldown: 1, ignoredUnverified: 0 }),
'Last started run #12; since then 2 firings ignored while a run was going, 1 firing ignored in the cooldown.',
)
assert.equal(
describeTriggerStarts({ lastRunId: null, ignoredRunning: 0, ignoredCooldown: 0, ignoredUnverified: 1 }),
'No run started by a trigger yet; since then 1 firing ignored because the published version is not verified.',
)
assert.equal(describeTriggerStarts({ lastRunId: 3 }), 'Last started run #3.')
})
test('the audit line names a trigger start', () => {
const line = describeLogLine({
kind: 'run.created',
detail: { version: 2, source: 'trigger', origin: { kind: 'trigger', label: 'Bradley destroyed', variables: { grid: 'G8' } } },
})
assert.equal(line, 'Occurrence created from version 2 — Started because “Bradley destroyed” fired: grid G8')
})