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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
110
client/test/eventStarts.test.js
Normal file
110
client/test/eventStarts.test.js
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user