feat(events): the authoring UI proper (Phase 13)
Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.
One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.
A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.
Two defects fixed in already-merged code:
* Creating an event has been impossible since Phase 6. `events/new` was added
beside `events/:id` and binds no param, and React Router ranks a static
segment above a dynamic one whatever the order -- so the editor was handed no
id and fetched /admin/events/undefined. Worse, the failure was invisible:
`!form` is true for every failed load, so the error state sat behind a
spinner that never stopped.
* 12b's searchable sources had no consumer. The server half shipped and the
only UI that reads a source never sent a term, so the 6,707-entry spawner
list was picked from a 2,000-entry truncation with nothing saying so.
Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -212,7 +212,11 @@ export default function App() {
|
||||
`/:id` — the same collision, on the other side of the wire. */}
|
||||
<Route path="events/actions" element={<EventActions />} />
|
||||
<Route path="events/runs/:runId" element={<EventRun />} />
|
||||
<Route path="events/new" element={<EventEditor />} />
|
||||
{/* ONE route, and `new` is a value of `:id` rather than a
|
||||
path beside it. A static `events/new` outranks the dynamic
|
||||
segment in React Router whatever the order, so the editor
|
||||
was handed no `id` at all and asked the API for
|
||||
`/admin/events/undefined`. */}
|
||||
<Route path="events/:id" element={<EventEditor />} />
|
||||
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
|
||||
server: every route under /admin/engagement re-gates to `admin`
|
||||
|
||||
@@ -492,12 +492,25 @@ export const api = {
|
||||
// editor with it. A refusal comes back 200 with `ok: false`, so this never
|
||||
// throws for the case the screen is meant to render: the field degrades to
|
||||
// free text with the reason beside it.
|
||||
eventOptions: (sourceId) => req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}`),
|
||||
// Phase 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is
|
||||
// ignored, never refused, by a source that does not declare itself
|
||||
// searchable — so passing it is always safe and the field decides whether
|
||||
// it is a typeahead by reading `searchable` off the answer.
|
||||
eventOptions: (sourceId, q) => {
|
||||
const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
|
||||
return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
|
||||
},
|
||||
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
|
||||
// who wrote the definition is who should be able to price it against the caps
|
||||
// before asking an admin to publish it. A report with findings comes back 200
|
||||
// — the request succeeded, the plan has problems.
|
||||
verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }),
|
||||
// Phase 13's live cap meter, and NOT a lighter dry run — it dispatches
|
||||
// nothing, so it knows nothing a module knows. It takes the spec in the
|
||||
// body rather than an id because the plan it prices is the one in the
|
||||
// author's hands, which is unsaved between keystrokes, and it records
|
||||
// nothing, which is what makes it safe to call on a debounce.
|
||||
priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }),
|
||||
// The switchboard, admin only in BOTH directions: reading which actions a
|
||||
// deployment permits is as much configuration as writing it (§K). One action
|
||||
// per write rather than the whole board, so an action that appeared between
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
// meets at 2am while the thing they are trying to stop keeps running — so the
|
||||
// guards are written twice on purpose and the copy is checked.
|
||||
|
||||
// **The condition builder is borrowed, not rebuilt.** §I says the step editor
|
||||
// reuses "the condition builder, exactly" — and a phase's advance gate is
|
||||
// literally the engagement grammar, validated on the server by
|
||||
// `engagement/conditions.js`. Importing the row helpers is what keeps this screen
|
||||
// from becoming a second opinion about a grammar core owns.
|
||||
import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js'
|
||||
|
||||
// A run that is over. Verbatim `eventRuns.db`'s TERMINAL.
|
||||
export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed']
|
||||
|
||||
@@ -115,14 +122,13 @@ const nextPhaseKey = (phases) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* A new step, with its params box PREFILLED from the action's declared examples.
|
||||
* A new step, with its params PREFILLED from the action's declared examples.
|
||||
*
|
||||
* Every param carries a required `example` — that requirement is the reason this
|
||||
* works — so a fresh `core.announce` step arrives as a JSON object with the right
|
||||
* keys and plausible values rather than as an empty `{}` an author has to guess
|
||||
* the shape of. It is the nearest a raw JSON box gets to the schema-driven form
|
||||
* Phase 13 replaces it with, and it costs nothing the catalog was not already
|
||||
* serving.
|
||||
* works — so a fresh `core.announce` step arrives with the right keys and
|
||||
* plausible values rather than empty. Phase 13 turned the box into a form and
|
||||
* this stayed exactly as it was: a form whose fields start at the declared
|
||||
* example is a step an author edits rather than one they compose.
|
||||
*/
|
||||
export function blankStep(action) {
|
||||
const params = {}
|
||||
@@ -152,7 +158,18 @@ export function blankPhase(phases) {
|
||||
* 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: '' }
|
||||
return { kind: '', after: '30m', on: '', count: 1, ...blankWhere() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `where` predicate as the BUILDER holds it (Phase 13).
|
||||
*
|
||||
* `whereText` survives beside the rows and is not vestigial: it is what a
|
||||
* predicate the builder cannot render is shown as, and what is posted for one.
|
||||
* See `whereFormFrom`.
|
||||
*/
|
||||
export function blankWhere() {
|
||||
return { whereOp: 'and', whereRows: [], whereEditable: true, whereText: '' }
|
||||
}
|
||||
|
||||
export const ADVANCE_KINDS = [
|
||||
@@ -161,7 +178,7 @@ export const ADVANCE_KINDS = [
|
||||
{ value: 'on', label: 'When something happens in the game' },
|
||||
]
|
||||
|
||||
/** The stored gate, as the form's three fields. */
|
||||
/** The stored gate, as the form's fields. */
|
||||
export function advanceFormFrom(advance) {
|
||||
const blank = blankAdvance()
|
||||
if (!advance) return blank
|
||||
@@ -171,7 +188,34 @@ export function advanceFormFrom(advance) {
|
||||
kind: 'on',
|
||||
on: advance.on || '',
|
||||
count: advance.count ?? 1,
|
||||
whereText: advance.where ? JSON.stringify(advance.where, null, 2) : '',
|
||||
...whereFormFrom(advance.where),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored `where` tree → the builder's flat rows (Phase 13).
|
||||
*
|
||||
* **This is `conditionRowsFrom` and it is deliberately the same function**, not a
|
||||
* second one shaped like it. The grammar behind a phase gate is the engagement
|
||||
* condition grammar — the server validates it with `engagement/conditions.js`
|
||||
* and renders the diagnosis panel's sentence with the same labels — so an editor
|
||||
* here that re-decided what a tree looks like would be the second implementation
|
||||
* §I refuses on the read side for exactly this reason.
|
||||
*
|
||||
* A tree the flat editor cannot hold (`A and (B or C)`) comes back
|
||||
* `whereEditable: false` and is SHOWN as its JSON rather than silently
|
||||
* flattened: `A and B and C` fires on different events, and an author would have
|
||||
* no way to know the save had done it to them.
|
||||
*/
|
||||
export function whereFormFrom(where) {
|
||||
const blank = blankWhere()
|
||||
if (!where) return blank
|
||||
const rows = conditionRowsFrom(where)
|
||||
return {
|
||||
whereOp: rows.op,
|
||||
whereRows: rows.rows,
|
||||
whereEditable: rows.editable,
|
||||
whereText: JSON.stringify(where, null, 2),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,25 +252,37 @@ 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.
|
||||
* **Whether the predicate is VALID is still the server's answer.** The builder
|
||||
* coerces each literal to the type the trigger DECLARED — which is not a second
|
||||
* validator but the thing that makes the first one's error useful: every value
|
||||
* in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int`
|
||||
* variable is refused by `engagement/conditions.js`, rightly, at which point the
|
||||
* author is reading an error about JSON rather than about what they typed.
|
||||
*
|
||||
* A predicate the builder could not render round-trips through `whereText`
|
||||
* unchanged. That is the point of keeping the text: the alternative to posting it
|
||||
* back verbatim is dropping an author's tree because this screen could not draw
|
||||
* it.
|
||||
*/
|
||||
export function advancePayload(advance, where, errors) {
|
||||
export function advancePayload(advance, where, errors, variables = []) {
|
||||
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}`)
|
||||
if (advance.whereEditable === false) {
|
||||
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
|
||||
}
|
||||
|
||||
const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables)
|
||||
if (built) out.where = built
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -244,10 +300,20 @@ export function advancePayload(advance, where, errors) {
|
||||
* applies the action's risk-class default rather than being told a value the
|
||||
* form invented.
|
||||
*/
|
||||
export function payloadFromForm(form) {
|
||||
export function payloadFromForm(form, { triggersById = new Map() } = {}) {
|
||||
const errors = []
|
||||
const phases = (form.phases || []).map((phase, pi) => {
|
||||
const where = advancePayload(phase.advance, `Phase ${pi + 1} "${phase.label || phase.key}"`, errors)
|
||||
const where = advancePayload(
|
||||
phase.advance,
|
||||
`Phase ${pi + 1} "${phase.label || phase.key}"`,
|
||||
errors,
|
||||
// The declared types the builder coerces against. A trigger nothing
|
||||
// registers has none, and every literal then stays the string it was typed
|
||||
// as — which is right: the gate is dormant, the server carries its `where`
|
||||
// through unvalidated, and inventing types for it here would edit a
|
||||
// predicate nobody can currently check.
|
||||
triggersById.get(phase.advance?.on)?.variables || [],
|
||||
)
|
||||
return {
|
||||
key: phase.key,
|
||||
label: phase.label,
|
||||
@@ -431,6 +497,157 @@ export function parseParams(text) {
|
||||
return { params: value }
|
||||
}
|
||||
|
||||
// ── Step params, as a form (Phase 13) ─────────────────────────────
|
||||
//
|
||||
// §I: the step editor is *"the condition builder, exactly — core serves a
|
||||
// catalog, the module declared the schema, core renders a form it does not
|
||||
// understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for
|
||||
// this, and everything the form needs was already in the catalog: a param's
|
||||
// name, type, whether it is required, its description, its example, and the
|
||||
// option source behind it.
|
||||
//
|
||||
// **The JSON stays as the storage and as the escape hatch, and both halves of
|
||||
// that matter.** As storage, because `payloadFromForm` already builds a request
|
||||
// out of it and a second representation would be two things to keep in step. As
|
||||
// an escape hatch, because a form can only render what the declaration
|
||||
// describes — and a step may legitimately hold something it does not.
|
||||
//
|
||||
// The rule for when the form gives way is the CONDITION BUILDER'S rule, which is
|
||||
// the reason this reads as a port of it rather than as a new idea: a value the
|
||||
// editor cannot round-trip is SHOWN rather than silently rewritten. Flattening
|
||||
// `A and (B or C)` there and dropping an undeclared param here are the same
|
||||
// mistake — a save that looks clean and means something else.
|
||||
|
||||
/** The two ways a step's params are edited. */
|
||||
export const PARAM_FORM = 'form'
|
||||
export const PARAM_JSON = 'json'
|
||||
|
||||
/**
|
||||
* Can this step's params be rendered as a form without losing anything?
|
||||
*
|
||||
* `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold.
|
||||
* Three things make one, and none of them is an error — each is a step that has
|
||||
* to be edited as JSON:
|
||||
*
|
||||
* • **the action is dormant.** There is no declaration, so there are no fields.
|
||||
* A form here would render nothing and look like a step with no params.
|
||||
* • **a param the action does not declare.** The save refuses it by name, which
|
||||
* is what the author needs to see — and a form that dropped it would post a
|
||||
* step that saves cleanly having deleted something they typed.
|
||||
* • **a value no single control can hold** — an object or an array against a
|
||||
* scalar declaration.
|
||||
*/
|
||||
export function paramsRenderable(action, params) {
|
||||
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
|
||||
const declared = new Map((action.params || []).map((p) => [p.name, p]))
|
||||
for (const [name, value] of Object.entries(params || {})) {
|
||||
if (!declared.has(name)) {
|
||||
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which mode should this step open in?
|
||||
*
|
||||
* The author's own choice wins whenever the form COULD render the step — an
|
||||
* author who switched to JSON stays in JSON. What they cannot do is stay in a
|
||||
* form that would lose something, so an unrenderable step is forced to JSON
|
||||
* whatever the choice was, and the reason is returned so the screen can say it.
|
||||
*/
|
||||
export function paramsMode(step, action) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error }
|
||||
const renderable = paramsRenderable(action, parsed.params)
|
||||
if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason }
|
||||
return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null }
|
||||
}
|
||||
|
||||
/** One declared param's current value, as the control holds it. */
|
||||
export function paramValue(step, name) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return undefined
|
||||
return parsed.params[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one param, and give back the whole box.
|
||||
*
|
||||
* **An empty field REMOVES the key rather than posting an empty string**, and
|
||||
* that is the server's own reading rather than a convenience: `checkParams`
|
||||
* treats `undefined`, `null` and `''` alike — absent — so a required param left
|
||||
* blank comes back as *"is required"*, which is the error the author needs,
|
||||
* instead of as a type complaint about `""`.
|
||||
*
|
||||
* **A value that does not parse is passed through as typed.** `coerceLiteral` is
|
||||
* the engagement builder's, unchanged, and its rule is the one that matters
|
||||
* here too: half of `-` is not a number, and turning it into `NaN` or `0` while
|
||||
* somebody is still typing would either post a value they never wrote or make
|
||||
* the field impossible to type a negative into. The server's type check then
|
||||
* names the param.
|
||||
*
|
||||
* Re-serialising the whole object rather than splicing text, for `pickParam`'s
|
||||
* reason: a string edit that produced valid-looking JSON with a duplicate key
|
||||
* would be a value the editor and the server read differently.
|
||||
*/
|
||||
export function setParam(step, name, raw, type) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return step?.paramsText || '{}'
|
||||
const next = { ...parsed.params }
|
||||
if (raw === '' || raw === undefined || raw === null) delete next[name]
|
||||
else next[name] = coerceLiteral(type, raw)
|
||||
return JSON.stringify(next, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored `datetime` as a `datetime-local` input wants it, and back.
|
||||
*
|
||||
* The server normalises a datetime param to an ISO string (`conditions.js`
|
||||
* `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The
|
||||
* slice is the whole conversion in one direction; in the other the input's own
|
||||
* text is a moment `new Date()` parses, so it is posted as typed and the server
|
||||
* does the normalising — one implementation of what a datetime is, and it is
|
||||
* not this one.
|
||||
*/
|
||||
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
|
||||
|
||||
/**
|
||||
* Everything the meter needs out of the form, and nothing else.
|
||||
*
|
||||
* The price route takes a spec, not a definition: no title, no schedule, no
|
||||
* series. Sending the whole payload would put a document in front of a route
|
||||
* that reads two fields of it — and would fail the moment the rest of the form
|
||||
* is mid-edit, which is exactly when the meter is being read.
|
||||
*
|
||||
* A step whose params do not parse is sent with none rather than dropped, so a
|
||||
* half-typed JSON box costs its own step's draw and not the phase's.
|
||||
*/
|
||||
export function priceBodyFrom(form) {
|
||||
return {
|
||||
phases: (form?.phases || []).map((phase) => ({
|
||||
key: phase.key || null,
|
||||
steps: (phase.steps || []).map((step) => ({
|
||||
actionId: step.actionId || '',
|
||||
params: parseParams(step.paramsText).params || {},
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this plan worth pricing at all?
|
||||
*
|
||||
* A meter that fires on an empty form asks the server what nothing costs, on
|
||||
* every keystroke of the title field. One step with an action chosen is the
|
||||
* threshold, because that is the first moment there is an answer.
|
||||
*/
|
||||
export const worthPricing = (form) =>
|
||||
(form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId))
|
||||
|
||||
// ── Rendering what happened ────────────────────────────────────────────────
|
||||
|
||||
const STATUS_WORDS = {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
payloadFromForm,
|
||||
blankPhase,
|
||||
blankAdvance,
|
||||
blankWhere,
|
||||
whereFormFrom,
|
||||
ADVANCE_KINDS,
|
||||
blankStep,
|
||||
describeSchedule,
|
||||
@@ -15,7 +17,16 @@ import {
|
||||
SCHEDULE_KINDS,
|
||||
MONTHLY_NTHS,
|
||||
WEEKDAYS,
|
||||
PARAM_FORM,
|
||||
PARAM_JSON,
|
||||
paramsMode,
|
||||
paramValue,
|
||||
setParam,
|
||||
datetimeInputValue,
|
||||
priceBodyFrom,
|
||||
worthPricing,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
import { operatorsForType } from '../../../lib/engagementRules.js'
|
||||
|
||||
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
@@ -32,13 +43,21 @@ import {
|
||||
// knowing any of it, and `check:modules` already fails core's build on a UO
|
||||
// identifier.
|
||||
//
|
||||
// **The params box is a raw JSON field and it is captioned as a placeholder**,
|
||||
// because that is what it is: Phase 13 replaces it with the schema-driven form
|
||||
// the condition builder already models. What makes it usable in the meantime is
|
||||
// that a new step arrives PREFILLED from the action's declared examples, and the
|
||||
// declaration is rendered beside the box — every param's name, type, whether it
|
||||
// is required, and what a value looks like. All of that was already in the
|
||||
// catalog; none of it is a second copy of anything.
|
||||
// **The params are a FORM as of Phase 13**, one control per declared param,
|
||||
// rendered from a schema core does not understand — §I's *"the condition
|
||||
// builder, exactly"*. The JSON box did not go away: it is the escape hatch, and
|
||||
// a step opens in it automatically when the form could not hold what the step
|
||||
// carries. That rule is the condition builder's own, ported rather than
|
||||
// reinvented — dropping a param the action does not declare and flattening
|
||||
// `A and (B or C)` are the same mistake, a save that looks clean and means
|
||||
// something else.
|
||||
//
|
||||
// **The meter beside the timeline is not a lighter dry run.** `POST
|
||||
// /admin/events/price` dispatches nothing, so it knows nothing a module knows —
|
||||
// whether the landmark exists, whether the shard is up. It answers the half core
|
||||
// can answer alone, which is what a plan would SPEND, and it can therefore run on
|
||||
// a debounce while somebody types. The dry run stays the thing that asks the
|
||||
// modules, and the screen labels them apart.
|
||||
|
||||
/**
|
||||
* The values behind one param's `source` (§F *Param option sources*, Phase 7).
|
||||
@@ -88,9 +107,7 @@ function ParamOptions({ entry, label, disabled, onPick }) {
|
||||
disabled={disabled}
|
||||
onChange={(e) => { if (e.target.value) onPick(e.target.value) }}
|
||||
>
|
||||
<option value="">
|
||||
{disabled ? 'Fix the params JSON to pick a value' : `Pick from ${label}…`}
|
||||
</option>
|
||||
<option value="">{`Pick from ${label}…`}</option>
|
||||
{grouped
|
||||
? groups.map((g) => (
|
||||
<optgroup key={g} label={g}>
|
||||
@@ -104,6 +121,484 @@ function ParamOptions({ entry, label, disabled, onPick }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A source too large for a dropdown, as a search box (Phase 13).
|
||||
*
|
||||
* **The first source that needed this made it unavoidable.** Phase 12b's spawner
|
||||
* target is 6,707 spawn points against `MAX_OPTIONS`' bound of 2,000, so a flat
|
||||
* list drops two thirds of the world and says nothing about which two thirds —
|
||||
* an author picking from it would be choosing from a truncation they cannot see.
|
||||
* The sidecar half of that shipped in 12b; this is what asks.
|
||||
*
|
||||
* `searchable` on the answer decides which control is drawn, rather than the
|
||||
* length of the list: inferring it from a truncated answer reads correctly right
|
||||
* up until a small deployment's list happens to fit, at which point the same
|
||||
* source is a dropdown on one shard and a search box on another.
|
||||
*
|
||||
* A term is sent on a debounce and the answer is dropped if it is not the one
|
||||
* for the term still in the box — a slow source answering after a faster one
|
||||
* would otherwise repaint the list under the author's cursor with results for
|
||||
* something they have finished typing.
|
||||
*/
|
||||
function SearchableOptions({ sourceId, label, disabled, onPick }) {
|
||||
const [term, setTerm] = useState('')
|
||||
const [state, setState] = useState({ status: 'idle', options: [] })
|
||||
const latest = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
const wanted = term.trim()
|
||||
latest.current = wanted
|
||||
if (!wanted) {
|
||||
setState({ status: 'idle', options: [] })
|
||||
return undefined
|
||||
}
|
||||
setState((s) => ({ ...s, status: 'loading' }))
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const answer = await api.admin.eventOptions(sourceId, wanted)
|
||||
if (latest.current !== wanted) return
|
||||
setState(
|
||||
answer?.ok
|
||||
? { status: 'ok', options: answer.options || [] }
|
||||
: { status: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
|
||||
)
|
||||
} catch (err) {
|
||||
if (latest.current !== wanted) return
|
||||
setState({ status: 'failed', options: [], reason: err.message || 'this list could not be read' })
|
||||
}
|
||||
}, 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [term, sourceId])
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
value={term}
|
||||
disabled={disabled}
|
||||
placeholder={`Search ${label}…`}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
{state.status === 'loading' && (
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>Searching…</div>
|
||||
)}
|
||||
{state.status === 'failed' && (
|
||||
<div className="sans" style={{ fontSize: '0.72rem', marginTop: 4, color: '#d98b84' }}>
|
||||
{state.reason} — type the value by hand.
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'ok' && state.options.length === 0 && (
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>
|
||||
Nothing matches “{term}”.
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'ok' && state.options.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: '4px 0 0', padding: 0, maxHeight: 160, overflowY: 'auto' }}>
|
||||
{state.options.map((o) => (
|
||||
<li key={o.value}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem', width: '100%', textAlign: 'left', marginBottom: 2 }}
|
||||
onClick={() => { onPick(o.value); setTerm('') }}
|
||||
>
|
||||
{o.label}
|
||||
{o.group && <span className="dim"> · {o.group}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One declared param, as the control its type implies (Phase 13).
|
||||
*
|
||||
* **Core does not know what any of this means and that is the design.** The
|
||||
* label is the param's own name, the help is its own description, the placeholder
|
||||
* is its own example, and the values behind it come from the module that declared
|
||||
* the source — `check:modules` fails core's build on a UO identifier, so there is
|
||||
* nowhere for a game word to be written here even by accident.
|
||||
*
|
||||
* Two of the controls are worth their own sentence:
|
||||
*
|
||||
* • **A boolean is a three-value select, not a checkbox.** A checkbox cannot say
|
||||
* *"not set"*, and for an OPTIONAL boolean that is a real third state — the
|
||||
* action's own default. A checkbox would post `false` for every param an author
|
||||
* never touched.
|
||||
* • **A source-backed param keeps its free-text field.** The dropdown writes
|
||||
* into it; it does not replace it. §F: a source is answered by a module that may
|
||||
* be talking to a sidecar, and an authoring form a shard outage can make
|
||||
* unusable is a worse failure than the typo the dropdown exists to prevent.
|
||||
*/
|
||||
function ParamField({ param, value, sourceEntry, disabled, onChange }) {
|
||||
const common = { className: 'input', disabled, style: { fontSize: '0.8rem' } }
|
||||
const asText = value === undefined || value === null ? '' : String(value)
|
||||
|
||||
let control
|
||||
if (param.type === 'boolean') {
|
||||
control = (
|
||||
<select
|
||||
{...common}
|
||||
value={value === true ? 'true' : value === false ? 'false' : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Not set{param.required ? '' : ' — the action decides'}</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
)
|
||||
} else if (param.type === 'datetime') {
|
||||
control = (
|
||||
<input
|
||||
{...common}
|
||||
type="datetime-local"
|
||||
value={datetimeInputValue(asText)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
} else if (param.type === 'int' || param.type === 'float') {
|
||||
control = (
|
||||
<input
|
||||
{...common}
|
||||
type="number"
|
||||
step={param.type === 'int' ? '1' : 'any'}
|
||||
value={asText}
|
||||
placeholder={param.example === undefined ? '' : String(param.example)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
} else if (param.type === 'url') {
|
||||
control = (
|
||||
<input {...common} type="url" value={asText}
|
||||
placeholder={param.example === undefined ? '' : String(param.example)}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
)
|
||||
} else {
|
||||
control = (
|
||||
<input {...common} value={asText}
|
||||
placeholder={param.example === undefined ? '' : String(param.example)}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">
|
||||
{param.name}
|
||||
{param.required && <span style={{ color: '#d98b84' }}> *</span>}
|
||||
<span className="dim" style={{ fontWeight: 'normal' }}> · {param.type}</span>
|
||||
</span>
|
||||
{control}
|
||||
{param.description && (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>{param.description}</span>
|
||||
)}
|
||||
{param.source && (
|
||||
sourceEntry?.searchable
|
||||
? (
|
||||
<SearchableOptions
|
||||
sourceId={param.source}
|
||||
label={sourceEntry.label || param.source}
|
||||
disabled={disabled}
|
||||
onPick={onChange}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<ParamOptions
|
||||
entry={sourceEntry}
|
||||
label={sourceEntry?.label || param.source}
|
||||
disabled={disabled}
|
||||
onPick={onChange}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The advance condition, as a builder rather than as JSON (Phase 13).
|
||||
*
|
||||
* **This is the engagement condition builder**, and being the same one is the
|
||||
* point rather than a saving: the grammar is `engagement/conditions.js`, the
|
||||
* server validates a phase gate with it, and the run console's diagnosis panel
|
||||
* renders its sentence from the same labels. A second editor here would be a
|
||||
* second opinion about a grammar core owns — exactly what §I refuses on the read
|
||||
* side, where the sentence is rendered on the server for the same reason.
|
||||
*
|
||||
* It offers the FLAT half of the grammar — one `and`/`or` over a list of
|
||||
* comparisons — because that is what a dropdown per operator can render
|
||||
* honestly. A tree it cannot hold opens READ-ONLY with its JSON showing and one
|
||||
* choice: leave it, or clear it and start again. Flattening `A and (B or C)`
|
||||
* 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 }) {
|
||||
const variables = trigger?.variables || []
|
||||
const rows = advance.whereRows || []
|
||||
|
||||
if (advance.whereEditable === false) {
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<span className="field-label">Only when</span>
|
||||
<pre className="dim" style={{ fontSize: '0.76rem', margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{advance.whereText}
|
||||
</pre>
|
||||
<p className="sans" style={{ fontSize: '0.74rem', color: '#d9c184', margin: '6px 0 0' }}>
|
||||
This condition nests, and the builder only holds one <code>and</code>/<code>or</code> over a
|
||||
flat list. It is kept exactly as authored and posted back unchanged — flattening it would
|
||||
change which firings release the phase without saying so.
|
||||
</p>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 6 }} disabled={disabled}
|
||||
onClick={() => onChange(blankWhere())}>
|
||||
Clear it and start again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const setRow = (i, patch) =>
|
||||
onChange({ whereRows: rows.map((r, j) => (j === i ? { ...r, ...patch } : r)) })
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<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.
|
||||
</p>
|
||||
)}
|
||||
{rows.length > 1 && (
|
||||
<label style={{ display: 'block', margin: '6px 0' }}>
|
||||
<span className="field-label">Match</span>
|
||||
<select className="input" style={{ maxWidth: 220, fontSize: '0.76rem' }} value={advance.whereOp}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ whereOp: e.target.value })}>
|
||||
<option value="and">all of these</option>
|
||||
<option value="or">any of these</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{rows.map((row, i) => {
|
||||
const declared = variables.find((v) => v.name === row.variable)
|
||||
const usable = operatorsForType(operators, declared?.type)
|
||||
const valueless = row.cmp === 'present' || row.cmp === 'absent'
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', marginTop: 6 }}>
|
||||
<select className="input" style={{ flex: '1 1 150px', fontSize: '0.76rem' }} value={row.variable}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setRow(i, { variable: e.target.value, cmp: '' })}>
|
||||
<option value="">Which variable…</option>
|
||||
{variables.map((v) => <option key={v.name} value={v.name}>{v.name} ({v.type})</option>)}
|
||||
</select>
|
||||
<select className="input" style={{ flex: '0 1 130px', fontSize: '0.76rem' }} value={row.cmp}
|
||||
disabled={disabled || !row.variable}
|
||||
onChange={(e) => setRow(i, { cmp: e.target.value })}>
|
||||
<option value="">is…</option>
|
||||
{usable.map((o) => <option key={o.cmp} value={o.cmp}>{o.label}</option>)}
|
||||
</select>
|
||||
{!valueless && (
|
||||
<input className="input" style={{ flex: '1 1 140px', fontSize: '0.76rem' }} value={row.value ?? ''}
|
||||
disabled={disabled || !row.cmp}
|
||||
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'Yew, Britain, Vesper' : ''}
|
||||
onChange={(e) => setRow(i, { value: e.target.value })} />
|
||||
)}
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={disabled}
|
||||
onClick={() => onChange({ whereRows: rows.filter((_, j) => j !== i) })}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 8 }}
|
||||
disabled={disabled || !variables.length}
|
||||
onClick={() => onChange({ whereRows: [...rows, { variable: '', cmp: '', value: '' }] })}>
|
||||
Add a clause
|
||||
</button>
|
||||
{!variables.length && advance.on && (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem', marginLeft: 8 }}>
|
||||
This trigger declares no variables, so there is nothing to narrow on.
|
||||
</span>
|
||||
)}
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>
|
||||
A list operator takes comma-separated values. Every literal is read as the type the trigger
|
||||
declared, and a variable it does not have comes back named from the save.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The live cap meter (§I, Phase 13).
|
||||
*
|
||||
* Two facts per dimension — what this plan draws, and what the deployment allows
|
||||
* — because that is all a cap is. §I says the same thing about the run console's
|
||||
* meter: *"a meter per dimension rather than a sentence, because unlike a gate a
|
||||
* cap is two numbers and a name and needs no grammar rendered to be read."*
|
||||
*
|
||||
* **It says what it does not know.** A step core could not price makes every
|
||||
* total below an under-count, and an author reading a number smaller than what
|
||||
* will happen is worse off than one reading no number at all. So `unpriced` is
|
||||
* rendered as prominently as the totals, not tucked underneath them.
|
||||
*
|
||||
* And it does not claim to be the dry run: the caption says so, because a green
|
||||
* meter beside a plan whose landmarks do not exist would otherwise read as a
|
||||
* pass.
|
||||
*/
|
||||
function CapMeter({ report, budgets, stale }) {
|
||||
const labelOf = (id) => budgets.find((b) => b.id === id)?.label || id
|
||||
const unitOf = (id) => budgets.find((b) => b.id === id)?.unit || ''
|
||||
if (!report) return null
|
||||
const nothing = report.cost.length === 0 && report.unpriced.length === 0
|
||||
return (
|
||||
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 12, opacity: stale ? 0.55 : 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||
<h3 className="sans" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
What this plan draws
|
||||
{stale && <span className="dim" style={{ fontWeight: 'normal' }}> · recalculating…</span>}
|
||||
</h3>
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{report.steps} step{report.steps === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{nothing && (
|
||||
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.8rem' }}>
|
||||
Nothing in this plan spends a capped resource.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{report.cost.length > 0 && (
|
||||
<table className="sans" style={{ fontSize: '0.8rem', borderCollapse: 'collapse', marginTop: 6 }}>
|
||||
<tbody>
|
||||
{report.cost.map((c) => (
|
||||
<tr key={c.dimension} style={{ color: c.over ? '#d98b84' : undefined }}>
|
||||
<td style={{ paddingRight: 12 }}>{labelOf(c.dimension)}</td>
|
||||
<td style={{ paddingRight: 12 }}>{c.total} {unitOf(c.dimension)}</td>
|
||||
<td className="dim">
|
||||
{c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{report.unpriced.length > 0 && (
|
||||
<div style={{ marginTop: 8, borderLeft: '3px solid #d9c184', paddingLeft: 10 }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
<strong>These totals are incomplete.</strong>
|
||||
</p>
|
||||
<ul className="sans" style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: '0.78rem' }}>
|
||||
{report.unpriced.map((u, i) => (
|
||||
<li key={`${u.phase}-${u.seq}-${u.code}-${i}`}>
|
||||
<code className="dim" style={{ fontSize: '0.76rem' }}>phase {u.phase + 1} · step {u.seq + 1}</code>
|
||||
{' — '}{u.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.74rem' }}>
|
||||
Arithmetic only — nothing was dispatched, so this does not know whether the places and things
|
||||
these steps name exist. The dry run asks the modules that do.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting a run, with the three things the route has always taken (Phase 13).
|
||||
*
|
||||
* **Two of them were unreachable from this screen until now**, and one of those
|
||||
* is not a nicety: `concurrencyKey` is a `{placeholder}` template rendered from
|
||||
* the RUN's own params, so an event whose key names one could not be started
|
||||
* correctly from the UI at all — every manual run rendered the same key and the
|
||||
* second one was refused as an overlap.
|
||||
*
|
||||
* **Rehearsal is a real run.** §I: the world changes are real, the announcements
|
||||
* are ceilinged to `staff`. It is not a dry run and the dialog says so, because
|
||||
* the two words are near enough to swap in a hurry.
|
||||
*/
|
||||
function StartDialog({ onStart, onCancel, busy }) {
|
||||
const [rehearsal, setRehearsal] = useState(false)
|
||||
const [scope, setScope] = useState('')
|
||||
const [paramsText, setParamsText] = useState('{}')
|
||||
const [problem, setProblem] = useState(null)
|
||||
|
||||
const go = () => {
|
||||
let params = null
|
||||
const text = paramsText.trim()
|
||||
if (text && text !== '{}') {
|
||||
try {
|
||||
params = JSON.parse(text)
|
||||
} catch (err) {
|
||||
setProblem(`The params are not valid JSON (${err.message})`)
|
||||
return
|
||||
}
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
||||
setProblem('The params must be a JSON object')
|
||||
return
|
||||
}
|
||||
}
|
||||
onStart({ rehearsal, scope: scope || undefined, ...(params ? { params } : {}) })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid var(--accent, #6d7f9c)' }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 10px', fontSize: '0.92rem' }}>Start a run now</h3>
|
||||
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: 10 }}>
|
||||
<input type="checkbox" checked={rehearsal} onChange={(e) => setRehearsal(e.target.checked)} />
|
||||
<span className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
<strong>Rehearsal.</strong>{' '}
|
||||
<span className="dim">
|
||||
A real run — every world change actually happens — with its announcements ceilinged to
|
||||
staff, so no player is told. This is not the dry run.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 12 }}>
|
||||
<label>
|
||||
<span className="field-label">Scope (optional)</span>
|
||||
<input className="input" value={scope} onChange={(e) => setScope(e.target.value)} />
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
Passed to the module verbatim. Core never parses it.
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Run params (optional)</span>
|
||||
<textarea className="input" rows={3} spellCheck={false}
|
||||
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.78rem' }}
|
||||
value={paramsText} onChange={(e) => setParamsText(e.target.value)} />
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
What the concurrency key’s <code>{'{placeholders}'}</code> are filled from.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{problem && (
|
||||
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84', margin: '10px 0 0' }}>{problem}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={go}>
|
||||
Start it
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DORMANT_NOTE =
|
||||
'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.'
|
||||
|
||||
@@ -127,6 +622,14 @@ export default function EventEditor() {
|
||||
// because a report is a statement about a spec and both of those change it —
|
||||
// a stale green report beside an edited plan is worse than no report.
|
||||
const [report, setReport] = useState(null)
|
||||
// Phase 13. The meter's answer, and whether the plan has moved since it was
|
||||
// asked — `stale` is what stops the number reading as current while a request
|
||||
// is in flight, which on a debounce it very often is not.
|
||||
const [priced, setPriced] = useState(null)
|
||||
const [priceStale, setPriceStale] = useState(false)
|
||||
// The start dialog is open. Not a modal: this screen is long, and a dialog
|
||||
// that covers the plan hides the thing being started.
|
||||
const [starting, setStarting] = useState(false)
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
// Reads here are staff-wide (§K), so a moderator reaches this screen legitimately
|
||||
@@ -207,7 +710,16 @@ export default function EventEditor() {
|
||||
setSources((s) => ({
|
||||
...s,
|
||||
[sourceId]: answer?.ok
|
||||
? { state: 'ok', label: answer.label, options: answer.options || [] }
|
||||
// `searchable` is the SOURCE's answer about itself (Phase 12b), not a
|
||||
// guess from how long the list came back. A source bounded at 2,000
|
||||
// that happens to have 40 entries on this deployment is still the one
|
||||
// that has to be searched on the shard where it has 6,707.
|
||||
? {
|
||||
state: 'ok',
|
||||
label: answer.label,
|
||||
searchable: Boolean(answer.searchable),
|
||||
options: answer.options || [],
|
||||
}
|
||||
: { state: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
|
||||
}))
|
||||
} catch (err) {
|
||||
@@ -221,36 +733,6 @@ export default function EventEditor() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Write one param into a step's JSON box, from a picked option.
|
||||
*
|
||||
* Re-serialising the whole object rather than splicing text: the box holds an
|
||||
* object the save path parses, and a string edit that produced valid-looking
|
||||
* JSON with a duplicate key would be a value the editor and the server read
|
||||
* differently. `2` because that is what `blankStep` writes, so picking a value
|
||||
* does not reformat the box under the author's cursor.
|
||||
*/
|
||||
const pickParam = (pi, si, step, name, value) => {
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(step.paramsText || '{}')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return
|
||||
setStep(pi, si, { paramsText: JSON.stringify({ ...parsed, [name]: value }, null, 2) })
|
||||
}
|
||||
|
||||
/** Does this step's JSON box currently hold an object we can write into? */
|
||||
const paramsParse = (step) => {
|
||||
try {
|
||||
const parsed = JSON.parse(step.paramsText || '{}')
|
||||
return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7. Every option source the steps on this page name, resolved once.
|
||||
//
|
||||
// An effect rather than a lookup at render time, because resolving one is a
|
||||
@@ -270,6 +752,52 @@ export default function EventEditor() {
|
||||
for (const id of wanted) loadSource(id)
|
||||
}, [form, actionById, loadSource])
|
||||
|
||||
/**
|
||||
* The live cap meter (§I, Phase 13).
|
||||
*
|
||||
* **A debounce and a generation counter, and the counter is not optional.**
|
||||
* Requests started 400ms apart do not necessarily answer in that order, and an
|
||||
* older answer landing last would leave the meter showing the cost of a plan
|
||||
* the author has already changed — stale in the one direction that matters,
|
||||
* silently, with nothing on the screen to say so.
|
||||
*
|
||||
* A failure leaves the LAST answer standing rather than blanking the meter or
|
||||
* raising an error box: this is an aid to authoring, and the plan is still
|
||||
* saveable, dry-runnable and publishable without it. The staleness marker is
|
||||
* how the screen stays honest about it.
|
||||
*/
|
||||
const priceGeneration = useRef(0)
|
||||
useEffect(() => {
|
||||
if (!form || !worthPricing(form)) {
|
||||
setPriced(null)
|
||||
setPriceStale(false)
|
||||
return undefined
|
||||
}
|
||||
setPriceStale(true)
|
||||
const generation = ++priceGeneration.current
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const answer = await api.admin.priceEvent(priceBodyFrom(form))
|
||||
if (priceGeneration.current !== generation) return
|
||||
setPriced(answer)
|
||||
setPriceStale(false)
|
||||
} catch {
|
||||
if (priceGeneration.current !== generation) return
|
||||
// Left stale on purpose: a number the screen cannot vouch for is shown
|
||||
// dimmed rather than replaced by nothing.
|
||||
setPriceStale(true)
|
||||
}
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [form])
|
||||
|
||||
/** The meter's per-phase rollup, by ordinal, for the timeline. */
|
||||
const drawByPhase = useMemo(
|
||||
() => new Map((priced?.phases || []).map((p) => [p.phase, p.draw])),
|
||||
[priced],
|
||||
)
|
||||
const budgets = useMemo(() => catalog?.budgets || [], [catalog])
|
||||
|
||||
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const setPhase = (pi, patch) =>
|
||||
@@ -330,7 +858,10 @@ export default function EventEditor() {
|
||||
// A report describes a spec, and saving changes it. A green report left
|
||||
// standing beside an edited plan is worse than no report at all.
|
||||
setReport(null)
|
||||
const built = payloadFromForm(form)
|
||||
// The declared variable types, so the builder's literals are coerced to
|
||||
// them before the request rather than compared as strings by a server that
|
||||
// will rightly refuse them.
|
||||
const built = payloadFromForm(form, { triggersById: triggerById })
|
||||
if (!built.ok) {
|
||||
setProblems(built.errors)
|
||||
setBusy(false)
|
||||
@@ -406,21 +937,34 @@ export default function EventEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
/**
|
||||
* Start a run, with what the dialog collected (Phase 13).
|
||||
*
|
||||
* The route has taken `rehearsal`, `scope` and `params` since Phase 10 and this
|
||||
* screen sent `{}` — so rehearsal, the affordance §I asks for by name, was
|
||||
* unreachable, and an event whose concurrency key names a `{placeholder}` could
|
||||
* not be started correctly at all: every manual run rendered the same key and
|
||||
* the second was refused as an overlap with the first.
|
||||
*/
|
||||
const start = async (body) => {
|
||||
setBusy(true)
|
||||
setProblems([])
|
||||
try {
|
||||
const result = await api.admin.startEventRun(id, {})
|
||||
const result = await api.admin.startEventRun(id, body)
|
||||
navigate(`/admin/events/runs/${result.run.id}`)
|
||||
} catch (err) {
|
||||
setProblems(err.body?.errors || [err.message])
|
||||
setStarting(false)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !form) return <Loading />
|
||||
// **The error is checked BEFORE the form.** `!form` is true for every failed
|
||||
// load, so testing it first put the error state permanently behind a spinner:
|
||||
// the screen that could not load said "Loading…" for ever and named nothing.
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (loading || !form) return <Loading />
|
||||
|
||||
const archived = event?.state === 'archived'
|
||||
|
||||
@@ -465,7 +1009,8 @@ export default function EventEditor() {
|
||||
</button>
|
||||
)}
|
||||
{!isNew && isAdmin && event?.state === 'ready' && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={start}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => setStarting((s) => !s)}>
|
||||
Start now
|
||||
</button>
|
||||
)}
|
||||
@@ -567,6 +1112,10 @@ export default function EventEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{starting && (
|
||||
<StartDialog busy={busy} onCancel={() => setStarting(false)} onStart={start} />
|
||||
)}
|
||||
|
||||
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: '#8fc79a' }}>{notice}</p>}
|
||||
|
||||
{problems.length > 0 && (
|
||||
@@ -715,6 +1264,12 @@ export default function EventEditor() {
|
||||
</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
|
||||
out they are over is an author who finds out too late. */}
|
||||
<CapMeter report={priced} budgets={budgets} stale={priceStale} />
|
||||
|
||||
{/* ── The phase timeline ── */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Phases</h3>
|
||||
@@ -754,6 +1309,20 @@ export default function EventEditor() {
|
||||
cannot change once runs exist.
|
||||
</p>
|
||||
|
||||
{/* §I asks the timeline for "phases in order, each with its steps, its
|
||||
advance condition, its cap draw and its failure policy". This is
|
||||
the cap draw, and it is the phase's own rather than a share of the
|
||||
total: an author moving a step between phases is asking exactly
|
||||
this question. */}
|
||||
{(drawByPhase.get(pi) || []).length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '4px 0 0' }}>
|
||||
Draws{' '}
|
||||
{(drawByPhase.get(pi) || [])
|
||||
.map((d) => `${d.total} ${budgets.find((b) => b.id === d.dimension)?.label || d.dimension}`)
|
||||
.join(' · ')}
|
||||
</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
|
||||
@@ -779,8 +1348,16 @@ export default function EventEditor() {
|
||||
<>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
{/* Changing the trigger CLEARS the predicate, for the same
|
||||
reason changing a step's action replaces its params:
|
||||
every clause names a variable of the old trigger, and the
|
||||
save would refuse each of them by name. Keeping them
|
||||
would look like tolerance and behave like a form that
|
||||
cannot be saved. */}
|
||||
<select className="input" value={phase.advance.on}
|
||||
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, on: e.target.value } })}>
|
||||
onChange={(e) => setPhase(pi, {
|
||||
advance: { ...phase.advance, on: e.target.value, ...blankWhere() },
|
||||
})}>
|
||||
<option value="">Choose a trigger…</option>
|
||||
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} — {t.id}</option>)}
|
||||
</select>
|
||||
@@ -795,24 +1372,13 @@ export default function EventEditor() {
|
||||
</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>
|
||||
<WhereBuilder
|
||||
advance={phase.advance}
|
||||
trigger={triggerById.get(phase.advance.on)}
|
||||
operators={catalog?.operators || []}
|
||||
disabled={archived}
|
||||
onChange={(patch) => setPhase(pi, { advance: { ...phase.advance, ...patch } })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
|
||||
@@ -825,6 +1391,9 @@ export default function EventEditor() {
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{phase.steps.map((step, si) => {
|
||||
const action = actionById.get(step.actionId)
|
||||
// Phase 13. Which editor this step gets, and — when it is not the
|
||||
// author's choice — why.
|
||||
const mode = paramsMode(step, action)
|
||||
return (
|
||||
<div key={si} style={{ borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12, marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
@@ -867,56 +1436,71 @@ export default function EventEditor() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)', gap: 12, marginTop: 10 }}>
|
||||
<label>
|
||||
<span className="field-label">Params (JSON)</span>
|
||||
<textarea className="input" rows={Math.max(4, (step.paramsText || '').split('\n').length)}
|
||||
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.8rem' }}
|
||||
value={step.paramsText} onChange={(e) => setStep(pi, si, { paramsText: e.target.value })} />
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
A raw JSON field, and a placeholder: the form that renders each param from its
|
||||
declared type arrives with the authoring pass. What is saved is checked
|
||||
against the declaration either way.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<span className="field-label">What this action takes</span>
|
||||
{action ? (
|
||||
<table className="adm-table" style={{ fontSize: '0.78rem' }}>
|
||||
<tbody>
|
||||
{(action.params || []).map((p) => (
|
||||
<tr key={p.name}>
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
|
||||
<code>{p.name}</code>
|
||||
{p.required && <span style={{ color: '#d98b84' }}> *</span>}
|
||||
</td>
|
||||
<td className="adm-td dim">{p.type}</td>
|
||||
<td className="adm-td">
|
||||
{p.description}
|
||||
<div className="dim">e.g. <code>{JSON.stringify(p.example)}</code></div>
|
||||
{p.source && (
|
||||
<ParamOptions
|
||||
entry={sources[p.source]}
|
||||
label={sources[p.source]?.label || p.source}
|
||||
disabled={!paramsParse(step)}
|
||||
onPick={(v) => pickParam(pi, si, step, p.name, v)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(action.params || []).length === 0 && (
|
||||
<tr><td className="adm-td dim">This action takes no params.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
Pick an action and its parameters are listed here, straight from what the
|
||||
module declared.
|
||||
</p>
|
||||
{/* ── The params (Phase 13) ──
|
||||
A form of the action's own declaration, with the JSON box
|
||||
kept as the escape hatch. A step the form cannot hold
|
||||
without losing something opens in JSON and says why — the
|
||||
condition builder's rule, and the same one, because
|
||||
dropping an undeclared param and flattening a nested
|
||||
condition are the same failure: a save that looks clean
|
||||
and means something else. */}
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||
<span className="field-label" style={{ marginBottom: 0 }}>Params</span>
|
||||
{!mode.forced && action && (action.params || []).length > 0 && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem' }}
|
||||
onClick={() => setStep(pi, si, {
|
||||
paramsMode: mode.mode === PARAM_JSON ? PARAM_FORM : PARAM_JSON,
|
||||
})}>
|
||||
{mode.mode === PARAM_JSON ? 'Edit as a form' : 'Edit as JSON'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode.forced && (
|
||||
<p className="sans" style={{ fontSize: '0.76rem', color: '#d9c184', margin: '6px 0 0' }}>
|
||||
Opened as JSON: {mode.reason}. Nothing has been dropped — what is here is
|
||||
exactly what was authored.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mode.mode === PARAM_FORM ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{(action?.params || []).map((p) => (
|
||||
<ParamField
|
||||
key={p.name}
|
||||
param={p}
|
||||
value={paramValue(step, p.name)}
|
||||
sourceEntry={p.source ? sources[p.source] : null}
|
||||
disabled={archived}
|
||||
onChange={(raw) => setStep(pi, si, { paramsText: setParam(step, p.name, raw, p.type) })}
|
||||
/>
|
||||
))}
|
||||
{action && (action.params || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
|
||||
This action takes no params.
|
||||
</p>
|
||||
)}
|
||||
{!action && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
|
||||
Pick an action and its fields appear here, straight from what the module
|
||||
declared.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea className="input" rows={Math.max(4, (step.paramsText || '').split('\n').length)}
|
||||
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.8rem', marginTop: 8 }}
|
||||
value={step.paramsText} onChange={(e) => setStep(pi, si, { paramsText: e.target.value })} />
|
||||
{action && (action.params || []).length > 0 && (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
<code>{action.id}</code> declares{' '}
|
||||
{(action.params || []).map((p) => `${p.name} (${p.type}${p.required ? ', required' : ''})`).join(', ')}.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,6 +23,17 @@ import {
|
||||
WEEKDAYS,
|
||||
MONTHLY_NTHS,
|
||||
ADVANCE_KINDS,
|
||||
blankWhere,
|
||||
whereFormFrom,
|
||||
paramsRenderable,
|
||||
paramsMode,
|
||||
paramValue,
|
||||
setParam,
|
||||
datetimeInputValue,
|
||||
priceBodyFrom,
|
||||
worthPricing,
|
||||
PARAM_FORM,
|
||||
PARAM_JSON,
|
||||
} from '../src/lib/eventAuthoring.js'
|
||||
|
||||
// lib/eventAuthoring.js — what the three Events screens say and what they let
|
||||
@@ -479,29 +490,97 @@ test('a gate round-trips through the form without losing the other shape', () =>
|
||||
assert.equal(ADVANCE_KINDS[0].value, '')
|
||||
})
|
||||
|
||||
test('advancePayload sends one shape, and only reports a JSON error', () => {
|
||||
test('advancePayload sends one shape, built from the builder\u2019s rows', () => {
|
||||
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),
|
||||
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', ...blankWhere() }, '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 = []
|
||||
// Whether the predicate is VALID is still the server's answer, named variable
|
||||
// and all \u2014 the builder only offers what the trigger declares, and a variable
|
||||
// that has gone away comes back named from the save.
|
||||
assert.deepEqual(
|
||||
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{"variable":"nope","cmp":"eq","value":1}' }, 'Phase 1', clean),
|
||||
advancePayload(
|
||||
{
|
||||
kind: 'on',
|
||||
on: 'x',
|
||||
count: 1,
|
||||
...blankWhere(),
|
||||
whereRows: [{ variable: 'nope', cmp: 'eq', value: '1' }],
|
||||
},
|
||||
'Phase 1',
|
||||
errors,
|
||||
[{ name: 'nope', type: 'int' }],
|
||||
),
|
||||
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
|
||||
)
|
||||
assert.equal(clean.length, 0)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
test('the builder coerces each literal to the type the trigger declared', () => {
|
||||
// The trap this closes: every value in an HTML input is a string, and
|
||||
// `{ cmp: 'gt', value: "5" }` against an int variable is refused by
|
||||
// engagement/conditions.js. Without this the author reads an error about JSON
|
||||
// rather than about what they typed.
|
||||
const built = advancePayload(
|
||||
{
|
||||
kind: 'on',
|
||||
on: 'x',
|
||||
count: 1,
|
||||
...blankWhere(),
|
||||
whereOp: 'or',
|
||||
whereRows: [
|
||||
{ variable: 'tier', cmp: 'gte', value: '3' },
|
||||
{ variable: 'region', cmp: 'in', value: 'Yew, Britain' },
|
||||
],
|
||||
},
|
||||
'Phase 1',
|
||||
[],
|
||||
[{ name: 'tier', type: 'int' }, { name: 'region', type: 'string' }],
|
||||
)
|
||||
assert.deepEqual(built.where, {
|
||||
op: 'or',
|
||||
nodes: [
|
||||
{ variable: 'tier', cmp: 'gte', value: 3 },
|
||||
{ variable: 'region', cmp: 'in', value: ['Yew', 'Britain'] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('a predicate the builder cannot render is posted back unchanged, not flattened', () => {
|
||||
// `A and (B or C)` is not `A and B and C` \u2014 they fire on different events \u2014
|
||||
// and an author would have no way to know the save had done it. The condition
|
||||
// builder's own rule, and this is the same function.
|
||||
const nested = {
|
||||
op: 'and',
|
||||
nodes: [
|
||||
{ variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||
{ op: 'or', nodes: [{ variable: 'tier', cmp: 'eq', value: 1 }, { variable: 'tier', cmp: 'eq', value: 2 }] },
|
||||
],
|
||||
}
|
||||
const form = whereFormFrom(nested)
|
||||
assert.equal(form.whereEditable, false)
|
||||
assert.deepEqual(form.whereRows, [])
|
||||
|
||||
const errors = []
|
||||
const built = advancePayload({ kind: 'on', on: 'x', count: 1, ...form }, 'Phase 1', errors)
|
||||
assert.deepEqual(built.where, nested, 'the tree survives a screen that cannot draw it')
|
||||
assert.equal(errors.length, 0)
|
||||
|
||||
// And the text is still the thing that can fail to parse, which is the only
|
||||
// reason this path keeps an error channel at all.
|
||||
advancePayload(
|
||||
{ kind: 'on', on: 'x', count: 1, whereEditable: false, whereText: '{ not json' },
|
||||
'Phase 2 "Boss"',
|
||||
errors,
|
||||
)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
|
||||
})
|
||||
|
||||
test('a phase with no gate sends no `advance` key', () => {
|
||||
@@ -602,3 +681,159 @@ test("the log renders Phase 6's three kinds, and a refusal does not read as a fa
|
||||
/Version 2 passed its dry run — scheduled occurrences may start/,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
// ── Step params as a form (Phase 13) ──────────────────────────────
|
||||
//
|
||||
// The form is not a boundary either — `events/spec.js` still decides what may be
|
||||
// saved. What is tested here is the thing that would be wrong SILENTLY: a form
|
||||
// that drops a param it cannot draw, or writes a value the author never typed.
|
||||
|
||||
const spawn = {
|
||||
id: 'test.spawn',
|
||||
label: 'Spawn',
|
||||
params: [
|
||||
{ name: 'creature', type: 'string', required: true, example: 'orc', source: 'test.creatures' },
|
||||
{ name: 'count', type: 'int', required: true, example: 8 },
|
||||
{ name: 'tame', type: 'boolean', required: false, example: false },
|
||||
{ name: 'at', type: 'datetime', required: false, example: '2026-09-07T20:00:00.000Z' },
|
||||
],
|
||||
}
|
||||
|
||||
const stepWith = (params, over = {}) => ({
|
||||
actionId: 'test.spawn',
|
||||
paramsText: JSON.stringify(params, null, 2),
|
||||
...over,
|
||||
})
|
||||
|
||||
test('a step whose params the form can hold opens as a form', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc', count: 8 }), spawn)
|
||||
assert.deepEqual(mode, { mode: PARAM_FORM, forced: false, reason: null })
|
||||
})
|
||||
|
||||
test('an author who chose JSON stays in JSON', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc' }, { paramsMode: PARAM_JSON }), spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, false, 'their choice, so no reason is shown')
|
||||
})
|
||||
|
||||
test('a param the action does not declare FORCES the JSON box and says which', () => {
|
||||
// The form would render four fields and post four values, having deleted
|
||||
// `radius` — a save that looks clean and means something else. The save path
|
||||
// refuses it by name, which is what the author needs to see.
|
||||
const mode = paramsMode(stepWith({ creature: 'orc', count: 8, radius: 12 }), spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /carries "radius", which test\.spawn does not declare/)
|
||||
})
|
||||
|
||||
test('a value no single control can hold forces the JSON box', () => {
|
||||
assert.match(paramsMode(stepWith({ creature: ['orc', 'troll'] }), spawn).reason, /holds a list/)
|
||||
assert.match(paramsMode(stepWith({ creature: { id: 'orc' } }), spawn).reason, /holds a structure/)
|
||||
})
|
||||
|
||||
test('a dormant step is edited as JSON, because there is no declaration to draw', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc' }), undefined)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /not installed/)
|
||||
})
|
||||
|
||||
test('a params box that is not JSON opens as JSON with the parse error', () => {
|
||||
const mode = paramsMode({ actionId: 'test.spawn', paramsText: '{ not json' }, spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /not valid JSON/)
|
||||
})
|
||||
|
||||
test('paramsRenderable accepts a step with nothing in it', () => {
|
||||
// A brand-new step with an optional-only action, and the empty case a form
|
||||
// needs to survive before anybody has typed.
|
||||
assert.deepEqual(paramsRenderable(spawn, {}), { ok: true })
|
||||
})
|
||||
|
||||
test('setParam writes the type the param declared, not the string the input held', () => {
|
||||
const step = stepWith({ creature: 'orc', count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'count', '12', 'int')), { creature: 'orc', count: 12 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'tame', 'true', 'boolean')), {
|
||||
creature: 'orc',
|
||||
count: 8,
|
||||
tame: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('a half-typed number is kept as typed rather than turned into NaN', () => {
|
||||
// `coerceLiteral`'s rule, and the reason it is borrowed rather than rewritten:
|
||||
// turning `-` into NaN while somebody types would either post a value they
|
||||
// never wrote or make a negative impossible to enter. The server's type check
|
||||
// then names the param.
|
||||
const step = stepWith({ count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'count', '-', 'int')), { count: '-' })
|
||||
})
|
||||
|
||||
test('clearing a field REMOVES the key rather than posting an empty string', () => {
|
||||
// `checkParams` treats undefined, null and '' alike — absent — so a required
|
||||
// param left blank comes back as "is required", which is the error the author
|
||||
// needs, instead of a type complaint about "".
|
||||
const step = stepWith({ creature: 'orc', count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'creature', '', 'string')), { count: 8 })
|
||||
})
|
||||
|
||||
test('setParam leaves an unparseable box alone rather than overwriting it', () => {
|
||||
// The only way to reach this is a race between the mode switch and a
|
||||
// keystroke; silently replacing the text with `{ "count": 1 }` would destroy
|
||||
// whatever the author was midway through writing.
|
||||
const step = { actionId: 'test.spawn', paramsText: '{ not json' }
|
||||
assert.equal(setParam(step, 'count', '1', 'int'), '{ not json')
|
||||
})
|
||||
|
||||
test('paramValue reads one param, and answers nothing for a box that does not parse', () => {
|
||||
assert.equal(paramValue(stepWith({ count: 8 }), 'count'), 8)
|
||||
assert.equal(paramValue(stepWith({ count: 8 }), 'creature'), undefined)
|
||||
assert.equal(paramValue({ paramsText: '{ not json' }, 'count'), undefined)
|
||||
})
|
||||
|
||||
test('a datetime is sliced to what the input wants, and anything else is empty', () => {
|
||||
assert.equal(datetimeInputValue('2026-09-07T20:00:00.000Z'), '2026-09-07T20:00')
|
||||
assert.equal(datetimeInputValue(undefined), '')
|
||||
assert.equal(datetimeInputValue(12), '')
|
||||
})
|
||||
|
||||
// ── The meter's request (Phase 13) ────────────────────────────
|
||||
|
||||
test('the price body carries the plan and nothing else', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'Invasion',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'warn', label: 'Warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
|
||||
{ key: 'assault', label: 'Assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
assert.deepEqual(priceBodyFrom(form), {
|
||||
phases: [
|
||||
{ key: 'warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
|
||||
{ key: 'assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('a step whose params do not parse is priced with none rather than dropped', () => {
|
||||
// Dropping it would move every step after it up an ordinal, so the meter's
|
||||
// "phase 2 step 3" would name a different step from the one on the screen.
|
||||
const form = {
|
||||
phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', paramsText: '{ not json' }] }] ,
|
||||
}
|
||||
assert.deepEqual(priceBodyFrom(form).phases[0].steps, [{ actionId: 'test.spawn', params: {} }])
|
||||
})
|
||||
|
||||
test('an empty plan is not worth pricing', () => {
|
||||
// Otherwise the meter asks the server what nothing costs on every keystroke of
|
||||
// the title field.
|
||||
assert.equal(worthPricing({ phases: [] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [] }] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: '' }] }] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: 'test.spawn' }] }] }), true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user