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

@@ -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