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
111 lines
4.4 KiB
JavaScript
111 lines
4.4 KiB
JavaScript
// 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')
|
|
})
|