diff --git a/client/src/api/client.js b/client/src/api/client.js index 8f979af..d847b85 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -522,6 +522,8 @@ export const api = { resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }), cancelEventRun: (runId, reason) => req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason } }), + advanceEventRun: (runId, reason) => + req(`/admin/events/runs/${runId}/advance`, { method: 'POST', body: { reason } }), confirmEventStep: (runId, stepId, note) => req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }), skipEventStep: (runId, stepId, reason) => diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js index 77b8073..ee437c1 100644 --- a/client/src/lib/eventAuthoring.js +++ b/client/src/lib/eventAuthoring.js @@ -47,14 +47,27 @@ export function lastStartedSeqOf(steps, phase) { * happen is cancelled, not paused. `cancel` is everything non-terminal — "this * is not happening" is a decision made before a run starts as often as during * one. + * + * **`advance` is offered only when the phase is genuinely waiting on its gate**, + * which is the same test the server makes and is stated here in the same words + * on purpose: this decides what is *offered*, the server decides what is + * *allowed*, and a button that is present and always refused is the "control + * that answers 409 and does nothing" this feature has refused twice. The gate + * must be open-and-unsatisfied AND no step of the phase may still be pending or + * running — a phase held by a step is held by the step, and skip is its control. */ -export function runControlsFor(run) { - if (!run) return { pause: false, resume: false, cancel: false } +export function runControlsFor(run, gates = [], steps = []) { + if (!run) return { pause: false, resume: false, cancel: false, advance: false } const terminal = isTerminalRun(run.status) + const gate = (gates || []).find((g) => g.phase === run.currentPhase) + const stepOpen = (steps || []).some( + (s) => s.phase === run.currentPhase && ['pending', 'running'].includes(s.status), + ) return { pause: ['starting', 'running'].includes(run.status), resume: run.status === 'paused', cancel: !terminal, + advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen, } } @@ -125,7 +138,41 @@ export function blankStep(action) { } export function blankPhase(phases) { - return { key: nextPhaseKey(phases), label: 'New phase', steps: [] } + return { key: nextPhaseKey(phases), label: 'New phase', steps: [], advance: blankAdvance() } +} + +/** + * The advance gate as the FORM holds it (Phase 5) — three fields that are + * always present and mostly empty, rather than a discriminated union the form + * has to rebuild every time the dropdown moves. + * + * `kind: ''` is "no condition", which is what nearly every phase is and what + * every phase was before this. The form keeps a half-typed `on` gate's trigger + * while the author looks at `after`, because a dropdown that discards what was + * typed under the other option is one an operator learns to be afraid of. + */ +export function blankAdvance() { + return { kind: '', after: '30m', on: '', count: 1, whereText: '' } +} + +export const ADVANCE_KINDS = [ + { value: '', label: 'When its steps are done' }, + { value: 'after', label: 'After a fixed delay' }, + { value: 'on', label: 'When something happens in the game' }, +] + +/** The stored gate, as the form's three fields. */ +export function advanceFormFrom(advance) { + const blank = blankAdvance() + if (!advance) return blank + if (advance.after !== undefined) return { ...blank, kind: 'after', after: advance.after } + return { + ...blank, + kind: 'on', + on: advance.on || '', + count: advance.count ?? 1, + whereText: advance.where ? JSON.stringify(advance.where, null, 2) : '', + } } /** The editor's working state, from what `GET /admin/events/:id` returned. */ @@ -145,6 +192,7 @@ export function formFromDefinition(event) { phases: (spec.phases || []).map((p) => ({ key: p.key || '', label: p.label || '', + advance: advanceFormFrom(p.advance), steps: (p.steps || []).map((s) => ({ actionId: s.actionId || '', label: s.label || '', @@ -157,6 +205,31 @@ export function formFromDefinition(event) { } } +/** + * One phase's advance gate, as the spec shape — or null when it has none. + * + * Only the `where` JSON is checked, and only because text that is not JSON + * cannot be put in a request at all. **Whether the predicate is VALID is the + * server's answer**, and the whole trap of Phase 5 is that it is answered at + * save with the offending variable named — re-deciding it here would be a second + * validator drifting from the one that matters, exactly as with a step's params. + */ +export function advancePayload(advance, where, errors) { + if (!advance || !advance.kind) return null + if (advance.kind === 'after') return { after: advance.after } + + const out = { on: advance.on, count: Number(advance.count) || 1 } + const text = String(advance.whereText || '').trim() + if (text) { + try { + out.where = JSON.parse(text) + } catch (err) { + errors.push(`${where}, advance condition: ${err.message}`) + } + } + return out +} + /** * The form, as a request body — or the list of everything wrong with it. * @@ -173,9 +246,16 @@ export function formFromDefinition(event) { */ export function payloadFromForm(form) { const errors = [] - const phases = (form.phases || []).map((phase, pi) => ({ + const phases = (form.phases || []).map((phase, pi) => { + const where = advancePayload(phase.advance, `Phase ${pi + 1} "${phase.label || phase.key}"`, errors) + return { key: phase.key, label: phase.label, + // Omitted rather than sent as null when there is no gate, which is what + // `events/spec.js` stores for the same reason: a spec full of + // `"advance": null` makes the first phase to gain one look like an edit to + // every phase in the version diff. + ...(where ? { advance: where } : {}), steps: (phase.steps || []).map((step, si) => { const out = { actionId: step.actionId } if (step.label) out.label = step.label @@ -188,7 +268,8 @@ export function payloadFromForm(form) { } return out }), - })) + } + }) if (errors.length) return { ok: false, errors } @@ -376,6 +457,9 @@ const KIND_WORDS = { 'step.status': 'Step', 'step.retry': 'Step retried', 'step.parked': 'Waiting on a human', + 'phase.gate': 'Advance condition set', + 'condition.evaluated': 'Condition evaluated', + 'phase.advanced': 'Phase advanced', note: 'Note', } @@ -415,6 +499,21 @@ export function describeLogLine(line) { : `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}` case 'run.created': return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}` + case 'phase.gate': + return d.kind === 'after' + ? `${line.phase} advances ${d.after} after it started` + : `${line.phase} advances on ${d.needed} × ${d.trigger}${d.where ? ` where ${d.where}` : ''}` + // Both outcomes are logged, and the near miss is the useful one: it is the + // difference between "the boss did spawn, in the wrong region" and "no boss + // has spawned", which look identical on every other line of this log. + case 'condition.evaluated': + return `${d.trigger} ${d.matched ? 'counted' : 'did not count'} — ${d.seen} of ${d.needed}${ + d.satisfied ? ', condition met' : '' + }` + case 'phase.advanced': + return d.because === 'forced' + ? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}` + : `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s` default: return logKindWord(line?.kind) } diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx index 2e37ff6..3ad300e 100644 --- a/client/src/routes/admin/views/EventEditor.jsx +++ b/client/src/routes/admin/views/EventEditor.jsx @@ -7,6 +7,8 @@ import { formFromDefinition, payloadFromForm, blankPhase, + blankAdvance, + ADVANCE_KINDS, blankStep, describeSchedule, scheduleFromForm, @@ -105,6 +107,12 @@ export default function EventEditor() { const actions = useMemo(() => catalog?.actions || [], [catalog]) const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions]) + // Phase 5. Served with the actions on the same route, so an EDITOR sees the + // same catalog an admin does — `/admin/engagement/triggers` is admin-only, and + // an editor writing a trigger id from memory into a field the save path then + // refuses is the failure this avoids. + const triggers = useMemo(() => catalog?.triggers || [], [catalog]) + const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers]) const set = (patch) => setForm((f) => ({ ...f, ...patch })) @@ -467,10 +475,77 @@ export default function EventEditor() {

The key is what the run console groups by and what “phase 3 has not started” names, so it - cannot change once runs exist. A phase advances when every one of its steps is finished; - advancing on a condition instead is a later phase. + cannot change once runs exist.

+ {/* ── The advance condition (Phase 5) ── + A gate is an ADDITIONAL condition and never a replacement, which is + what the caption has to say: a phase whose steps are still running + is not advanced by a boss that spawned early. */} +
+
+ + {phase.advance?.kind === 'after' && ( + + )} + {phase.advance?.kind === 'on' && ( + <> + + + + )} +
+ + {phase.advance?.kind === 'on' && ( +