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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user