feat(events): the authoring UI proper (Phase 13) #195
@@ -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)
|
||||
})
|
||||
|
||||
@@ -545,6 +545,15 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/price",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs",
|
||||
|
||||
@@ -241,6 +241,10 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog/options/:sourceId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/price"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs"
|
||||
|
||||
185
server/src/events/price.js
Normal file
185
server/src/events/price.js
Normal file
@@ -0,0 +1,185 @@
|
||||
// ── The live cap meter ─────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I, Phase 13: the step editor's "live cap meter". What would this
|
||||
// plan spend, and what does this deployment allow?
|
||||
//
|
||||
// **It is not the dry run, and the difference is the whole reason it exists.**
|
||||
// `verify.js` dispatches every step with `verify: true` — through the module,
|
||||
// and through the module to a sidecar and a game tick — and a pass against a
|
||||
// published version is RECORDED, because that record is what §K's gate reads
|
||||
// before letting a schedule start something unattended. Both of those are right
|
||||
// for an act an author performs once, deliberately, when the plan is finished.
|
||||
// Neither is right for a number that has to move while somebody types: a meter
|
||||
// on the dry run's path would put a shard round trip behind every keystroke and
|
||||
// would stamp `verified_at` from a form still being edited.
|
||||
//
|
||||
// So this file answers the half of the question core can answer ON ITS OWN:
|
||||
// `cost()` is a pure function of params (§F), and the caps come from the
|
||||
// switchboard. Nothing is dispatched, nothing is written, and no definition need
|
||||
// exist — the body is the spec in the author's hands, saved or not.
|
||||
//
|
||||
// **What it therefore cannot tell you** is everything the module knows: whether
|
||||
// the landmark exists, whether the creature is on the allowlist, whether the
|
||||
// shard is reachable. That is the dry run's, and the meter must not read as a
|
||||
// substitute for it — which is why the editor keeps both and labels them apart.
|
||||
//
|
||||
// ## Why the per-phase subtotal is here rather than computed in the browser
|
||||
//
|
||||
// §I asks the timeline for "its cap draw" per phase, and the arithmetic is
|
||||
// trivial — but the *inputs* are not in the browser. `cost()` runs on the server
|
||||
// and only on the server; a client that summed anything would first have to be
|
||||
// handed per-step costs, which is this same call. Returning the phase rollup
|
||||
// beside the total costs one pass over a list core has already walked.
|
||||
|
||||
const authorize = require('./authorize')
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const registries = require('../modules/registries')
|
||||
const spec = require('./spec')
|
||||
|
||||
/**
|
||||
* Flatten `{ phases: [{ key, steps: [...] }] }` into the priceable steps.
|
||||
*
|
||||
* Bounded by the spec's own limits rather than by a number invented here: this
|
||||
* route takes an unsaved spec, so it is reachable with a body the save path
|
||||
* would refuse, and the paste guard has to be the same one.
|
||||
*/
|
||||
function flatten(body) {
|
||||
const phases = Array.isArray(body?.phases) ? body.phases : []
|
||||
if (phases.length > spec.MAX_PHASES) {
|
||||
return { ok: false, error: `at most ${spec.MAX_PHASES} phases` }
|
||||
}
|
||||
const flat = []
|
||||
for (const [index, phase] of phases.entries()) {
|
||||
const steps = Array.isArray(phase?.steps) ? phase.steps : []
|
||||
if (steps.length > spec.MAX_STEPS_PER_PHASE) {
|
||||
return { ok: false, error: `at most ${spec.MAX_STEPS_PER_PHASE} steps in one phase` }
|
||||
}
|
||||
for (const [seq, step] of steps.entries()) {
|
||||
flat.push({
|
||||
// The key is what the editor groups by, and an unsaved phase may not
|
||||
// have a valid one yet — so the ordinal is what is echoed back. A meter
|
||||
// that could only address a phase whose key already validates would go
|
||||
// blank exactly while somebody is naming it.
|
||||
phase: index,
|
||||
phaseKey: typeof phase?.key === 'string' ? phase.key : null,
|
||||
seq,
|
||||
actionId: typeof step?.actionId === 'string' ? step.actionId : '',
|
||||
params: step && typeof step.params === 'object' && !Array.isArray(step.params) ? step.params : {},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (flat.length > spec.MAX_STEPS) {
|
||||
return { ok: false, error: `at most ${spec.MAX_STEPS} steps in one definition` }
|
||||
}
|
||||
return { ok: true, flat }
|
||||
}
|
||||
|
||||
/**
|
||||
* Price a spec.
|
||||
*
|
||||
* **A step core cannot price is reported, never treated as free.** Three things
|
||||
* make one: no module registers the action, the action's `cost()` failed its own
|
||||
* contract (`priceOf` answers `null`), or it prices a dimension nobody declared.
|
||||
* All three make the totals below an UNDER-count, and a meter that silently
|
||||
* under-counts is worse than no meter — it is a number an author trusts that is
|
||||
* smaller than what will happen. So each one comes back in `unpriced` with the
|
||||
* step it belongs to, and the client shows the meter as incomplete.
|
||||
*
|
||||
* The third is not a refusal to price: an action that spends `uo.creatures`
|
||||
* spends it whether or not a module declared the dimension, so the amount is
|
||||
* still counted and the entry says the total is *unenforceable* rather than
|
||||
* unknown. Same split `authorize.undeclaredDimensions` makes for the same
|
||||
* reason.
|
||||
*/
|
||||
async function priceSpec(body) {
|
||||
const flattened = flatten(body)
|
||||
if (!flattened.ok) return { ok: false, error: flattened.error }
|
||||
const { flat } = flattened
|
||||
|
||||
const settings = await settingsDb.byIds(flat.map((s) => s.actionId))
|
||||
const totals = {}
|
||||
const byPhase = new Map()
|
||||
const unpriced = []
|
||||
let priced = 0
|
||||
|
||||
const addTo = (bag, dimension, amount) => {
|
||||
bag[dimension] = (bag[dimension] || 0) + amount
|
||||
}
|
||||
|
||||
for (const step of flat) {
|
||||
if (!byPhase.has(step.phase)) {
|
||||
byPhase.set(step.phase, { phase: step.phase, key: step.phaseKey, steps: 0, draw: {} })
|
||||
}
|
||||
const phase = byPhase.get(step.phase)
|
||||
phase.steps += 1
|
||||
|
||||
const where = { phase: step.phase, seq: step.seq, actionId: step.actionId || null }
|
||||
const action = step.actionId ? registries.eventAction(step.actionId) : null
|
||||
if (!action) {
|
||||
// A step with no action chosen yet is not a problem — it is a form being
|
||||
// filled in — so it is not reported. A step naming an action nothing
|
||||
// registers is, because that is the dormant case and it under-counts.
|
||||
if (step.actionId) {
|
||||
unpriced.push({ ...where, code: 'dormant', message: `no module registers "${step.actionId}"` })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const cost = authorize.priceOf(action, step.params)
|
||||
if (cost === null) {
|
||||
unpriced.push({ ...where, code: 'unpriceable', message: `"${action.label}" could not report what it costs` })
|
||||
continue
|
||||
}
|
||||
priced += 1
|
||||
for (const [dimension, amount] of Object.entries(cost)) {
|
||||
addTo(totals, dimension, amount)
|
||||
addTo(phase.draw, dimension, amount)
|
||||
}
|
||||
for (const dimension of authorize.undeclaredDimensions(cost)) {
|
||||
unpriced.push({
|
||||
...where,
|
||||
code: 'undeclared',
|
||||
message: `spends "${dimension}", which no installed module declares as a budget — this step is refused at dispatch`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const caps = authorize.effectiveCaps(
|
||||
flat.map((s) => ({ actionId: s.actionId, params: s.params })),
|
||||
settings,
|
||||
)
|
||||
|
||||
const cost = Object.entries(totals)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dimension, total]) => {
|
||||
const cap = (caps[dimension] || {}).cap ?? null
|
||||
return {
|
||||
dimension,
|
||||
total,
|
||||
cap,
|
||||
from: (caps[dimension] || {}).from || null,
|
||||
over: cap !== null && total > cap,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
steps: flat.length,
|
||||
priced,
|
||||
cost,
|
||||
// Ordinal order, because that is timeline order and the client draws it
|
||||
// beside each phase. A phase whose steps price to nothing still appears, so
|
||||
// the rollup and the timeline have the same number of rows.
|
||||
phases: [...byPhase.values()].map((p) => ({
|
||||
phase: p.phase,
|
||||
key: p.key,
|
||||
steps: p.steps,
|
||||
draw: Object.entries(p.draw)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dimension, total]) => ({ dimension, total })),
|
||||
})),
|
||||
unpriced,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { priceSpec }
|
||||
@@ -33,6 +33,7 @@ const logDb = require('../../../model/events/eventRunLog.db')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const settingsDb = require('../../../model/events/eventActionSettings.db')
|
||||
const authorize = require('../../../events/authorize')
|
||||
const price = require('../../../events/price')
|
||||
|
||||
const asId = (raw) => {
|
||||
const n = Number(raw)
|
||||
@@ -466,6 +467,44 @@ exports.verify = async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/events/price — the live cap meter (Phase 13).
|
||||
*
|
||||
* `admin, editor`, exactly as the dry run is and for the same reason: an author
|
||||
* should be able to find out what their plan would cost before asking an admin
|
||||
* to commit the deployment to it.
|
||||
*
|
||||
* **The spec is in the BODY, not looked up by id**, and that is the whole point
|
||||
* of the route. The meter answers a question about the plan in the author's
|
||||
* hands — half-typed, unsaved, and quite possibly not publishable yet — so a
|
||||
* route that read the stored draft would be answering about a spec the author is
|
||||
* no longer looking at.
|
||||
*
|
||||
* It dispatches nothing, unlike `verify`, and it records nothing, unlike a dry
|
||||
* run that passes against a version — which is the stamp §K's unattended-start
|
||||
* gate reads. Those two absences are exactly what make it safe to call while
|
||||
* somebody is still typing.
|
||||
*
|
||||
* A body core cannot make sense of is a `400`; a plan that is over the caps is a
|
||||
* **200**, for the dry run's reason — *"this asks for 45 and you allow 30"* is
|
||||
* an answer, not a failed request.
|
||||
*
|
||||
* Not logged to the activity trail. It is a read that changes nothing and it
|
||||
* fires on a debounce while a form is edited; an audit line per keystroke would
|
||||
* bury the acts that matter under the act of looking.
|
||||
*/
|
||||
exports.price = async (req, res) => {
|
||||
const result = await price.priceSpec(req.body || {})
|
||||
if (!result.ok) return res.status(400).json({ error: result.error })
|
||||
return res.json({
|
||||
steps: result.steps,
|
||||
priced: result.priced,
|
||||
cost: result.cost,
|
||||
phases: result.phases,
|
||||
unpriced: result.unpriced,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/events/actions — the deployment's switchboard.
|
||||
*
|
||||
|
||||
@@ -83,6 +83,30 @@ eventsRouter.get(
|
||||
controller.options,
|
||||
)
|
||||
|
||||
// ── The live cap meter (Phase 13) ──────────────────────────────────
|
||||
//
|
||||
// A literal path for the same reason `/actions` is one, and `admin, editor` for
|
||||
// the same reason `verify` is: it dispatches nothing and it prices an author's
|
||||
// own work.
|
||||
//
|
||||
// **It takes a spec rather than an id**, which is what separates it from the dry
|
||||
// run. A meter has to answer about the form as it stands, and the form is not
|
||||
// saved between keystrokes.
|
||||
|
||||
eventsRouter.post(
|
||||
'/price',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Price an unsaved spec against the per-run caps, dispatching nothing'
|
||||
// #swagger.description = 'EVENTS.md I, the step editor live cap meter (Phase 13). What would this plan spend, and what does this deployment allow? The spec is in the BODY rather than looked up by id, and that is the whole point: the meter answers about the plan in the author hands -- half-typed, unsaved, quite possibly not publishable yet -- so a route that read the stored draft would be answering about a spec the author is no longer looking at. It is NOT the dry run and must not read as a substitute for one: nothing is dispatched, so nothing here knows whether the landmark exists or the shard is reachable, and nothing is recorded, so it never stamps the verification that EVENTS.md K unattended-start gate reads. Those two absences are exactly what make it safe to call on a debounce while somebody types. A step core cannot price is reported in `unpriced` rather than counted as free -- no module registers the action, its cost() failed its own contract, or it spends a dimension nobody declares -- because a meter that silently under-counts is worse than no meter. `phases` is the per-phase draw the timeline renders beside each phase. A plan over the caps is a 200, for the dry run reason: asking for 45 when 30 is allowed is an answer, not a failed request.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { phases: { type: "array", items: { type: "object", properties: { key: { type: "string" }, steps: { type: "array", items: { type: "object", properties: { actionId: { type: "string" }, params: { type: "object", additionalProperties: true } } } } } } } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The draw per dimension, the draw per phase, and every step that could not be priced', content: { "application/json": { schema: { type: "object", properties: { steps: { type: "integer" }, priced: { type: "integer" }, cost: { type: "array", items: { type: "object", properties: { dimension: { type: "string" }, total: { type: "integer" }, cap: { type: "integer" }, from: { type: "string" }, over: { type: "boolean" } } } }, phases: { type: "array", items: { type: "object", additionalProperties: true } }, unpriced: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The body is over the spec size limits', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.price,
|
||||
)
|
||||
|
||||
// ── The switchboard (Phase 6) ──────────────────────────────────────────────
|
||||
//
|
||||
// A literal path, so it is declared up here with `/catalog` rather than beside
|
||||
|
||||
@@ -4056,6 +4056,138 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/events/price": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "Price an unsaved spec against the per-run caps, dispatching nothing",
|
||||
"description": "EVENTS.md I, the step editor live cap meter (Phase 13). What would this plan spend, and what does this deployment allow? The spec is in the BODY rather than looked up by id, and that is the whole point: the meter answers about the plan in the author hands -- half-typed, unsaved, quite possibly not publishable yet -- so a route that read the stored draft would be answering about a spec the author is no longer looking at. It is NOT the dry run and must not read as a substitute for one: nothing is dispatched, so nothing here knows whether the landmark exists or the shard is reachable, and nothing is recorded, so it never stamps the verification that EVENTS.md K unattended-start gate reads. Those two absences are exactly what make it safe to call on a debounce while somebody types. A step core cannot price is reported in `unpriced` rather than counted as free -- no module registers the action, its cost() failed its own contract, or it spends a dimension nobody declares -- because a meter that silently under-counts is worse than no meter. `phases` is the per-phase draw the timeline renders beside each phase. A plan over the caps is a 200, for the dry run reason: asking for 45 when 30 is allowed is an answer, not a failed request.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The draw per dimension, the draw per phase, and every step that could not be priced",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"steps": {
|
||||
"type": "integer"
|
||||
},
|
||||
"priced": {
|
||||
"type": "integer"
|
||||
},
|
||||
"cost": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dimension": {
|
||||
"type": "string"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
},
|
||||
"cap": {
|
||||
"type": "integer"
|
||||
},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"over": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"phases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"unpriced": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The body is over the spec size limits",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not an admin or editor",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/events/runs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
309
server/test/eventPrice.test.js
Normal file
309
server/test/eventPrice.test.js
Normal file
@@ -0,0 +1,309 @@
|
||||
// ── The live cap meter (EVENTS_PLAN.md Phase 13) ───────────────────────────
|
||||
//
|
||||
// `events/price.js` answers what a plan would spend without dispatching a thing.
|
||||
// Three of the tests below protect a decision rather than a mechanism, and they
|
||||
// are the reason this file exists apart from `eventVerify.test.js`:
|
||||
//
|
||||
// • **A step core cannot price is reported, never counted as free.** All three
|
||||
// ways that happens — a dormant action, a `cost()` that broke its own
|
||||
// contract, and a dimension nobody declared — make the totals an UNDER-count,
|
||||
// and a meter an author trusts that reads lower than what will happen is
|
||||
// worse than no meter at all.
|
||||
// • **An undeclared dimension is still counted.** It is unenforceable, not
|
||||
// unknown: the action really will try to spend it, and the step is refused at
|
||||
// dispatch for that reason. Reporting it as costing nothing would hide both
|
||||
// facts at once.
|
||||
// • **The route dispatches nothing.** An action whose `perform()` would throw
|
||||
// prices perfectly well here, which is what makes the meter safe on a
|
||||
// debounce — `verify` puts a module and a sidecar behind every call and this
|
||||
// deliberately does not.
|
||||
//
|
||||
// The registry is the real one, staged and applied the way a module does it, for
|
||||
// `eventAuthorize.test.js`'s reason: an action that would not register is not one
|
||||
// this file has to survive.
|
||||
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const price = require('../src/events/price')
|
||||
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originalSettings = { ...settingsDb }
|
||||
|
||||
let settings
|
||||
|
||||
beforeEach(() => {
|
||||
registries._reset()
|
||||
settings = new Map()
|
||||
settingsDb.byIds = async (ids) =>
|
||||
new Map([...new Set(ids || [])].filter((i) => settings.has(i)).map((i) => [i, settings.get(i)]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(settingsDb, originalSettings)
|
||||
registries._reset()
|
||||
})
|
||||
|
||||
const action = (id, over = {}) => ({
|
||||
id,
|
||||
label: over.label || id,
|
||||
risk: over.risk || 'notify',
|
||||
reversible: over.reversible || 'none',
|
||||
params: over.params || [],
|
||||
...(over.cost ? { cost: over.cost } : {}),
|
||||
async perform() {
|
||||
return over.perform ? over.perform() : { ok: true }
|
||||
},
|
||||
})
|
||||
|
||||
const register = (entries, { owner = 'test', budgets = [] } = {}) => {
|
||||
const api = registries.stage(owner)
|
||||
api.registerEventActions(entries)
|
||||
if (budgets.length) api.registerEventBudgets(budgets.map((id) => ({ id, label: id, unit: 'count' })))
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
const setCaps = (id, caps) => settings.set(id, { action_id: id, enabled: 1, caps })
|
||||
|
||||
/** `{ phases: [...] }` out of a compact `[[step, step], [step]]`. */
|
||||
const spec = (phases) => ({
|
||||
phases: phases.map((steps, i) => ({
|
||||
key: `phase${i + 1}`,
|
||||
steps: steps.map(([actionId, params = {}]) => ({ actionId, params })),
|
||||
})),
|
||||
})
|
||||
|
||||
const dimension = (report, id) => report.cost.find((c) => c.dimension === id)
|
||||
|
||||
// ── The whole-plan total, which is the number the meter exists to show ──────
|
||||
|
||||
test('adds a dimension up across every phase and compares it to the tightest cap', async () => {
|
||||
register(
|
||||
[
|
||||
action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) }),
|
||||
action('test.boss', { cost: () => ({ 'test.creatures': 1 }) }),
|
||||
],
|
||||
{ budgets: ['test.creatures'] },
|
||||
)
|
||||
setCaps('test.spawn', { 'test.creatures': 30 })
|
||||
// The tightest cap wins: two actions spending one dimension have to agree on
|
||||
// one number, and a safety limit settles on the smaller.
|
||||
setCaps('test.boss', { 'test.creatures': 24 })
|
||||
|
||||
const report = await price.priceSpec(
|
||||
spec([
|
||||
[['test.spawn', { count: 15 }]],
|
||||
[['test.spawn', { count: 15 }], ['test.boss', {}]],
|
||||
]),
|
||||
)
|
||||
|
||||
assert.equal(report.ok, true)
|
||||
assert.equal(report.steps, 3)
|
||||
assert.equal(report.priced, 3)
|
||||
assert.deepEqual(dimension(report, 'test.creatures'), {
|
||||
dimension: 'test.creatures',
|
||||
total: 31,
|
||||
cap: 24,
|
||||
from: 'test.boss',
|
||||
over: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('a plan inside its cap is not over', async () => {
|
||||
register([action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) })], {
|
||||
budgets: ['test.creatures'],
|
||||
})
|
||||
setCaps('test.spawn', { 'test.creatures': 30 })
|
||||
|
||||
const report = await price.priceSpec(spec([[['test.spawn', { count: 12 }]]]))
|
||||
assert.equal(dimension(report, 'test.creatures').over, false)
|
||||
})
|
||||
|
||||
test('a dimension nobody caps comes back uncapped rather than missing', async () => {
|
||||
register([action('test.say', { cost: () => ({ 'test.broadcasts': 1 }) })], { budgets: ['test.broadcasts'] })
|
||||
|
||||
const report = await price.priceSpec(spec([[['test.say', {}]]]))
|
||||
assert.deepEqual(dimension(report, 'test.broadcasts'), {
|
||||
dimension: 'test.broadcasts',
|
||||
total: 1,
|
||||
cap: null,
|
||||
from: null,
|
||||
over: false,
|
||||
})
|
||||
})
|
||||
|
||||
// ── The per-phase draw the timeline renders ────────────────────────────────
|
||||
|
||||
test('reports the draw per phase, in timeline order, including a phase that spends nothing', async () => {
|
||||
register(
|
||||
[
|
||||
action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) }),
|
||||
action('test.wait'),
|
||||
],
|
||||
{ budgets: ['test.creatures'] },
|
||||
)
|
||||
|
||||
const report = await price.priceSpec(
|
||||
spec([
|
||||
[['test.spawn', { count: 8 }]],
|
||||
[['test.wait', {}]],
|
||||
[['test.spawn', { count: 4 }], ['test.spawn', { count: 2 }]],
|
||||
]),
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
report.phases.map((p) => ({ phase: p.phase, key: p.key, steps: p.steps, draw: p.draw })),
|
||||
[
|
||||
{ phase: 0, key: 'phase1', steps: 1, draw: [{ dimension: 'test.creatures', total: 8 }] },
|
||||
// A phase whose steps cost nothing still appears, so the rollup and the
|
||||
// timeline have the same number of rows.
|
||||
{ phase: 1, key: 'phase2', steps: 1, draw: [] },
|
||||
{ phase: 2, key: 'phase3', steps: 2, draw: [{ dimension: 'test.creatures', total: 6 }] },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('a phase is addressed by its ordinal, so an unnamed one still meters', async () => {
|
||||
register([action('test.spawn', { cost: () => ({ 'test.creatures': 3 }) })], { budgets: ['test.creatures'] })
|
||||
|
||||
// The key a half-typed phase carries may not validate yet. A meter that could
|
||||
// only address a phase whose key is already legal would go blank exactly while
|
||||
// somebody is naming it.
|
||||
const report = await price.priceSpec({ phases: [{ steps: [{ actionId: 'test.spawn', params: {} }] }] })
|
||||
assert.equal(report.phases[0].phase, 0)
|
||||
assert.equal(report.phases[0].key, null)
|
||||
assert.equal(dimension(report, 'test.creatures').total, 3)
|
||||
})
|
||||
|
||||
// ── The three ways a step cannot be priced ─────────────────────────────────
|
||||
|
||||
test('a step naming an action nothing registers is reported, not silently free', async () => {
|
||||
register([action('test.spawn', { cost: () => ({ 'test.creatures': 5 }) })], { budgets: ['test.creatures'] })
|
||||
|
||||
const report = await price.priceSpec(spec([[['test.spawn', {}], ['uo.creature.spawn', {}]]]))
|
||||
|
||||
assert.equal(report.steps, 2)
|
||||
assert.equal(report.priced, 1)
|
||||
assert.deepEqual(report.unpriced, [
|
||||
{
|
||||
phase: 0,
|
||||
seq: 1,
|
||||
actionId: 'uo.creature.spawn',
|
||||
code: 'dormant',
|
||||
message: 'no module registers "uo.creature.spawn"',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('a step with no action chosen yet is not a problem', async () => {
|
||||
register([action('test.spawn', { cost: () => ({ 'test.creatures': 5 }) })], { budgets: ['test.creatures'] })
|
||||
|
||||
// A form being filled in, not a plan with a hole in it. Reporting it would put
|
||||
// a red line on the screen for every step the moment it is added.
|
||||
const report = await price.priceSpec(spec([[['test.spawn', {}], ['', {}]]]))
|
||||
assert.deepEqual(report.unpriced, [])
|
||||
assert.equal(report.steps, 2)
|
||||
assert.equal(report.priced, 1)
|
||||
})
|
||||
|
||||
test('an action whose cost() breaks its own contract is unpriceable, not free', async () => {
|
||||
register(
|
||||
[
|
||||
action('test.broken', {
|
||||
label: 'Broken',
|
||||
cost: () => {
|
||||
throw new Error('nope')
|
||||
},
|
||||
}),
|
||||
],
|
||||
{ budgets: [] },
|
||||
)
|
||||
|
||||
const report = await price.priceSpec(spec([[['test.broken', {}]]]))
|
||||
assert.equal(report.priced, 0)
|
||||
assert.equal(report.unpriced.length, 1)
|
||||
assert.equal(report.unpriced[0].code, 'unpriceable')
|
||||
assert.match(report.unpriced[0].message, /could not report what it costs/)
|
||||
})
|
||||
|
||||
test('an undeclared dimension is COUNTED and reported as unenforceable', async () => {
|
||||
// The split `authorize.undeclaredDimensions` makes, for the same reason: the
|
||||
// action really will try to spend it — the step is refused at dispatch for
|
||||
// exactly this — so the amount is true and the enforcement is what is missing.
|
||||
register([action('test.spawn', { cost: () => ({ 'test.creatures': 9 }) })], { budgets: [] })
|
||||
|
||||
const report = await price.priceSpec(spec([[['test.spawn', {}]]]))
|
||||
assert.equal(dimension(report, 'test.creatures').total, 9)
|
||||
assert.equal(report.priced, 1)
|
||||
assert.equal(report.unpriced[0].code, 'undeclared')
|
||||
assert.match(report.unpriced[0].message, /refused at dispatch/)
|
||||
})
|
||||
|
||||
// ── What makes it safe to call while somebody types ────────────────────────
|
||||
|
||||
test('prices without dispatching: an action whose perform() throws still meters', async () => {
|
||||
register(
|
||||
[
|
||||
action('test.spawn', {
|
||||
cost: () => ({ 'test.creatures': 7 }),
|
||||
perform: () => {
|
||||
throw new Error('the shard is down')
|
||||
},
|
||||
}),
|
||||
],
|
||||
{ budgets: ['test.creatures'] },
|
||||
)
|
||||
|
||||
const report = await price.priceSpec(spec([[['test.spawn', {}]]]))
|
||||
assert.equal(dimension(report, 'test.creatures').total, 7)
|
||||
assert.deepEqual(report.unpriced, [])
|
||||
})
|
||||
|
||||
test('an empty plan prices to nothing rather than failing', async () => {
|
||||
const report = await price.priceSpec({})
|
||||
assert.deepEqual(report, { ok: true, steps: 0, priced: 0, cost: [], phases: [], unpriced: [] })
|
||||
})
|
||||
|
||||
// ── The paste guard, which is the spec's own and not a number invented here ──
|
||||
|
||||
test('refuses a body over the spec size limits', async () => {
|
||||
const spawn = { actionId: 'test.spawn', params: {} }
|
||||
const tooManyPhases = { phases: Array.from({ length: 41 }, (_, i) => ({ key: `p${i}`, steps: [] })) }
|
||||
assert.deepEqual(await price.priceSpec(tooManyPhases), { ok: false, error: 'at most 40 phases' })
|
||||
|
||||
const tooManySteps = { phases: [{ key: 'p', steps: Array.from({ length: 101 }, () => spawn) }] }
|
||||
assert.deepEqual(await price.priceSpec(tooManySteps), {
|
||||
ok: false,
|
||||
error: 'at most 100 steps in one phase',
|
||||
})
|
||||
|
||||
// 40 x 100 is over MAX_STEPS while breaking neither of the two bounds above.
|
||||
const tooManyOverall = {
|
||||
phases: Array.from({ length: 40 }, (_, i) => ({
|
||||
key: `p${i}`,
|
||||
steps: Array.from({ length: 100 }, () => spawn),
|
||||
})),
|
||||
}
|
||||
assert.deepEqual(await price.priceSpec(tooManyOverall), {
|
||||
ok: false,
|
||||
error: 'at most 500 steps in one definition',
|
||||
})
|
||||
})
|
||||
|
||||
test('a step whose params are not an object is priced as no params rather than throwing', async () => {
|
||||
register([action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 1 }) })], {
|
||||
budgets: ['test.creatures'],
|
||||
})
|
||||
|
||||
const report = await price.priceSpec({
|
||||
phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', params: ['not', 'an', 'object'] }] }],
|
||||
})
|
||||
assert.equal(dimension(report, 'test.creatures').total, 1)
|
||||
})
|
||||
@@ -24,6 +24,10 @@
|
||||
// both buttons, would behave badly in exactly the case the moderator role
|
||||
// exists for.
|
||||
//
|
||||
// Phase 13 adds the meter beside `verify`, and it is the one route here that
|
||||
// neither dispatches nor records — which is what makes it safe to call on a
|
||||
// debounce while a form is edited.
|
||||
//
|
||||
// Phase 6 adds the switchboard to the `admin` column — §K puts it in the same row
|
||||
// as the world-changing actions it governs — and `verify` to the `admin, editor`
|
||||
// one, because a dry run dispatches nothing and the author who wrote the
|
||||
@@ -154,6 +158,9 @@ const SURFACE = [
|
||||
['DELETE', '/events/series/1', ['admin', 'editor']],
|
||||
// Phase 6. A dry run dispatches nothing and changes nothing.
|
||||
['POST', '/events/1/verify', ['admin', 'editor']],
|
||||
// Phase 13's meter, in the same column and for a stronger version of the
|
||||
// same reason: it dispatches nothing AND records nothing.
|
||||
['POST', '/events/price', ['admin', 'editor']],
|
||||
|
||||
// Committing the deployment: admin only (§N2).
|
||||
['POST', '/events/1/publish', ['admin']],
|
||||
@@ -214,7 +221,9 @@ test('start and stop are NOT the same gate, and that is the point', async () =>
|
||||
test('an editor may price an event but not publish or start it', async () => {
|
||||
// Phase 6's addition to the same shape: the author who wrote the definition can
|
||||
// find out what it would cost before asking an admin to commit the deployment.
|
||||
// Phase 13 gave the same author the meter, on the same argument.
|
||||
assert.equal(await forbidden('POST', '/events/1/verify', 'editor'), false)
|
||||
assert.equal(await forbidden('POST', '/events/price', 'editor'), false)
|
||||
assert.equal(await forbidden('POST', '/events/1/publish', 'editor'), true)
|
||||
assert.equal(await forbidden('POST', '/events/1/runs', 'editor'), true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user