feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s

A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.

`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.

One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.

A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
2026-09-02 22:11:20 -05:00
parent 9c23c5fd0e
commit 9bc0bf5a3d
26 changed files with 2646 additions and 51 deletions

View File

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

View File

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

View File

@@ -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() {
</div>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
The key is what the run console groups by and what &ldquo;phase 3 has not started&rdquo; 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.
</p>
{/* ── 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. */}
<div style={{ marginTop: 12, borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">This phase advances</span>
<select className="input" value={phase.advance?.kind || ''}
onChange={(e) => setPhase(pi, { advance: { ...(phase.advance || blankAdvance()), kind: e.target.value } })}>
{ADVANCE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
</select>
</label>
{phase.advance?.kind === 'after' && (
<label style={{ flex: '0 1 160px' }}>
<span className="field-label">Delay</span>
<input className="input" value={phase.advance.after}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, after: e.target.value } })}
placeholder="30m" />
</label>
)}
{phase.advance?.kind === 'on' && (
<>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Trigger</span>
<select className="input" value={phase.advance.on}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, on: e.target.value } })}>
<option value="">Choose a trigger</option>
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} {t.id}</option>)}
</select>
</label>
<label style={{ flex: '0 1 110px' }}>
<span className="field-label">How many</span>
<input className="input" type="number" min="1" value={phase.advance.count}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, count: e.target.value } })} />
</label>
</>
)}
</div>
{phase.advance?.kind === 'on' && (
<label style={{ display: 'block', marginTop: 10 }}>
<span className="field-label">Only when (JSON, optional)</span>
<textarea className="input" rows={3} spellCheck={false} value={phase.advance.whereText}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, whereText: e.target.value } })}
placeholder={'{ "variable": "region", "cmp": "eq", "value": "Yew" }'} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
A raw JSON field, and a placeholder for the same reason the params box is one the
condition builder proper is a later phase. It is checked at save against what the
trigger declares, and a variable the trigger does not have comes back named.
{triggerById.get(phase.advance.on)?.variables?.length > 0 && (
<>
{' '}
<code>{phase.advance.on}</code> declares{' '}
{triggerById.get(phase.advance.on).variables.map((v) => `${v.name} (${v.type})`).join(', ')}.
</>
)}
</span>
</label>
)}
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
{phase.advance?.kind
? 'This is in ADDITION to its steps: the phase waits until every step has finished AND this is met. Nothing times out — if the condition never happens, the run is marked stalled and a person advances it from the run console.'
: 'The phase advances the moment every one of its steps is finished.'}
</p>
</div>
<div style={{ marginTop: 12 }}>
{phase.steps.map((step, si) => {
const action = actionById.get(step.actionId)

View File

@@ -29,6 +29,15 @@ import {
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
// stay that way for ever unless somebody presses confirm. It is called out above
// the step list rather than being one row in it.
//
// **Phase 5 gave it a second one of those, and the panel is this phase's real
// deliverable** (§ Observability): a phase whose steps have all finished and
// whose advance condition has not been met is also `running` and also
// healthy-looking. *"Why didn't phase 3 start?"* is answered here, above the
// steps, in the condition builder's own words — and the sentence is the
// SERVER'S. `gates[].where` arrives already rendered, because those labels are
// defined in the condition grammar and a second renderer in the browser would
// be a second opinion about what `gte` reads as.
const POLL_MS = 5000
@@ -52,11 +61,89 @@ const STEP_COLOR = {
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
/**
* Seconds as an operator reads them — the same vocabulary the spec authors a
* gate in, so "28 min" on this screen and `after: '30m'` in the editor are
* obviously the same kind of thing.
*/
function elapsed(seconds) {
const s = Math.max(0, Number(seconds) || 0)
if (s < 60) return `${s} sec`
if (s < 3600) return `${Math.floor(s / 60)} min`
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
return m ? `${h} hr ${m} min` : `${h} hr`
}
/**
* One phase gate, as the panel draws it.
*
* The satisfied ones are drawn too, and dimmed: "phase 2 waited 41 minutes and
* was released by the third boss" is the same question as the live one, asked
* after the fact, and it is the one an operator asks the morning after.
*/
function GateRow({ gate, current }) {
const colour = gate.satisfied ? 'var(--muted)' : gate.stalled ? '#d98b84' : '#d9c184'
return (
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
Phase <strong>{gate.phase}</strong>
{current && !gate.satisfied ? ' has not started' : ''}
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
{gate.stalled && ' — STALLED'}
</div>
<dl className="sans" style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px', margin: '6px 0 0', fontSize: '0.8rem' }}>
{gate.kind === 'after' ? (
<>
<dt className="dim">waiting for</dt>
<dd style={{ margin: 0 }}>{elapsed(gate.after)} from the start of the phase</dd>
<dt className="dim">until</dt>
<dd style={{ margin: 0 }}>{when(gate.dueAt)}</dd>
</>
) : (
<>
<dt className="dim">waiting on</dt>
<dd style={{ margin: 0 }}>
<code>{gate.waitingOn}</code>
{gate.where ? <> where <em>{gate.where}</em></> : <span className="dim"> any firing</span>}
</dd>
<dt className="dim">seen so far</dt>
<dd style={{ margin: 0 }}>{gate.seen} of {gate.needed}</dd>
</>
)}
<dt className="dim">since</dt>
<dd style={{ margin: 0 }}>{when(gate.since)} ({elapsed(gate.elapsedSeconds)})</dd>
{gate.kind === 'on' && gate.lastEvent && (
<>
<dt className="dim">last related event</dt>
<dd style={{ margin: 0 }}>
<code>{gate.lastEvent.trigger}</code> at {clock(gate.lastEventAt)}
{' — '}
{/* The near miss is the valuable half: "the boss did spawn, in
Britain" and "no boss has spawned" are different answers and
look identical without this line. */}
{gate.lastEvent.matched ? 'counted' : 'did not count'}
{Object.keys(gate.lastEvent.variables || {}).length > 0 && (
<span className="dim">
{' ('}
{Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}
{')'}
</span>
)}
</dd>
</>
)}
</dl>
</div>
)
}
export default function EventRun() {
const { runId } = useParams()
const [run, setRun] = useState(null)
const [steps, setSteps] = useState([])
const [counts, setCounts] = useState({})
const [gates, setGates] = useState([])
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -75,6 +162,7 @@ export default function EventRun() {
setRun(detail.run)
setSteps(detail.steps || [])
setCounts(detail.counts || {})
setGates(detail.gates || [])
setLines(log.log || [])
}, [runId])
@@ -130,7 +218,8 @@ export default function EventRun() {
if (error) return <ErrorState message={error} />
if (!run) return <ErrorState message="No such run." />
const controls = runControlsFor(run)
const controls = runControlsFor(run, gates, steps)
const waiting = gates.find((g) => g.phase === run.currentPhase && !g.satisfied)
const parked = steps.filter(isParked)
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
@@ -206,6 +295,10 @@ export default function EventRun() {
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
Resume
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.advance}
onClick={() => act(() => api.admin.advanceEventRun(run.id, reason))}>
Advance phase
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
Cancel run
@@ -219,6 +312,36 @@ export default function EventRun() {
</p>
)}
{/* ── Why this phase has not started (Phase 5) ──
Above the step list for the same reason the parked cue is: a phase
waiting on a condition is `running` and looks completely healthy, and
the one screen an operator opens to find out why nothing is happening
must say so before they have to read a log. */}
{gates.length > 0 && (
<div
className="panel-flat"
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
</h3>
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
{waiting ? (
<>
Every step of this phase has finished. It advances when the condition below is met
nothing times out, and <em>Advance phase</em> is how a person overrides it.
{waiting.stalled && ' This one has been waiting long enough that the run is marked stalled.'}
</>
) : (
'What each phase of this run waited for, and what released it.'
)}
</p>
{gates.map((gate) => (
<GateRow key={gate.phase} gate={gate} current={gate.phase === run.currentPhase} />
))}
</div>
)}
{/* ── Waiting on a person ── */}
{parked.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>

View File

@@ -16,8 +16,12 @@ import {
scheduleFormFrom,
scheduleFromForm,
isProjected,
blankAdvance,
advanceFormFrom,
advancePayload,
WEEKDAYS,
MONTHLY_NTHS,
ADVANCE_KINDS,
} from '../src/lib/eventAuthoring.js'
// lib/eventAuthoring.js — what the three Events screens say and what they let
@@ -416,3 +420,140 @@ test('a projection is told apart from a run, because only one of them can be act
assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
assert.equal(isProjected(null), false)
})
// -- The advance gate (Phase 5) ---------------------------------------------
//
// What this screen must get right is what it OFFERS. `advance` is the one
// control in this feature whose whole point is that it overrides the engine, so
// a button offered in a state the server refuses would be the "control that
// answers 409 and does nothing" this feature has refused twice.
test('advance is offered only when the phase is waiting on its gate', () => {
const gate = (over = {}) => [{ phase: 'boss', satisfied: false, ...over }]
const done = [{ phase: 'boss', status: 'done' }]
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), done).advance, true)
// A phase with an open step is held by the STEP, and skip is its control.
assert.equal(
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [...done, { phase: 'boss', status: 'pending' }]).advance,
false,
)
assert.equal(
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [{ phase: 'boss', status: 'running' }]).advance,
false,
)
// A phase with no gate advances on its steps and always has.
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, [], done).advance, false)
// A gate already satisfied is not waiting.
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate({ satisfied: true }), done).advance, false)
// And a run that is not running is waiting on nothing.
for (const status of ['scheduled', 'starting', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
assert.equal(runControlsFor({ status, currentPhase: 'boss' }, gate(), done).advance, false, status)
}
})
test('runControlsFor still answers with no gates or steps at all', () => {
// The three Phase 3 controls were called with one argument for two phases, and
// the calendar still calls it that way.
const controls = runControlsFor({ status: 'running', currentPhase: 'boss' })
assert.equal(controls.pause, true)
assert.equal(controls.advance, false)
})
test('a gate round-trips through the form without losing the other shape', () => {
assert.deepEqual(advanceFormFrom(null), blankAdvance())
assert.equal(advanceFormFrom({ after: '2h' }).kind, 'after')
assert.equal(advanceFormFrom({ after: '2h' }).after, '2h')
const on = advanceFormFrom({ on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 3 })
assert.equal(on.kind, 'on')
assert.equal(on.count, 3)
assert.deepEqual(JSON.parse(on.whereText), { variable: 'region', cmp: 'eq', value: 'Yew' })
// The dropdown's three options, and the empty one is what nearly every phase
// is — so it is first and it is not called "none".
assert.equal(ADVANCE_KINDS[0].value, '')
})
test('advancePayload sends one shape, and only reports a JSON error', () => {
const errors = []
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
assert.deepEqual(
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', whereText: '' }, 'Phase 1', errors),
{ on: 'uo.champ.boss_up', count: 2 },
'an empty predicate is omitted, not sent as an empty object',
)
assert.equal(errors.length, 0)
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{ not json' }, 'Phase 2 "Boss"', errors)
assert.equal(errors.length, 1)
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
// Whether the predicate is VALID is the server's answer, named variable and
// all. This only refuses text that cannot be put in a request.
const clean = []
assert.deepEqual(
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{"variable":"nope","cmp":"eq","value":1}' }, 'Phase 1', clean),
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
)
assert.equal(clean.length, 0)
})
test('a phase with no gate sends no `advance` key', () => {
const form = formFromDefinition({
title: 'x',
spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
})
const built = payloadFromForm(form)
assert.equal(built.ok, true)
assert.equal('advance' in built.payload.spec.phases[0], false)
})
test('an authored gate survives the round trip through the form', () => {
const form = formFromDefinition({
title: 'x',
spec: {
schedule: { kind: 'manual' },
phases: [
{ key: 'boss', label: 'Boss', steps: [], advance: { on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 2 } },
{ key: 'loot', label: 'Loot', steps: [], advance: { after: '10m' } },
],
},
})
const built = payloadFromForm(form)
assert.equal(built.ok, true)
assert.deepEqual(built.payload.spec.phases[0].advance, {
on: 'uo.champ.boss_up',
where: { variable: 'region', cmp: 'eq', value: 'Yew' },
count: 2,
})
assert.deepEqual(built.payload.spec.phases[1].advance, { after: '10m' })
})
test('the log renders Phase 5\'s three kinds, including the near miss', () => {
assert.match(
describeLogLine({ kind: 'phase.gate', phase: 'boss', detail: { kind: 'on', trigger: 'uo.champ.boss_up', needed: 2, where: 'region is "Yew"' } }),
/boss advances on 2 × uo\.champ\.boss_up where region is "Yew"/,
)
assert.match(describeLogLine({ kind: 'phase.gate', phase: 'loot', detail: { kind: 'after', after: '10m' } }), /loot advances 10m after it started/)
assert.match(
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: false, seen: 0, needed: 2 } }),
/did not count — 0 of 2/,
)
assert.match(
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: true, seen: 2, needed: 2, satisfied: true } }),
/counted — 2 of 2, condition met/,
)
assert.match(
describeLogLine({ kind: 'phase.advanced', phase: 'boss', detail: { because: 'forced', waitedSeconds: 4080, reason: 'never spawned' } }),
/boss advanced by hand after 4080s: never spawned/,
)
assert.match(
describeLogLine({ kind: 'phase.advanced', phase: 'loot', detail: { because: 'elapsed', waitedSeconds: 600 } }),
/loot advanced on its deadline after 600s/,
)
})