diff --git a/client/src/api/client.js b/client/src/api/client.js index 92b4e1c..536d068 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -518,6 +518,10 @@ export const api = { const qs = q ? `?${new URLSearchParams({ q }).toString()}` : '' return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`) }, + // MODULE_API 1.13.0. Fill an event template in; the answer is a draft the + // editor opens, and nothing is saved until the author saves it. + buildEventTemplate: (templateId, inputs) => + req(`/admin/events/catalog/templates/${encodeURIComponent(templateId)}`, { method: 'POST', body: { inputs } }), // 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 diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js index 84c4bed..cd99f9d 100644 --- a/client/src/lib/eventAuthoring.js +++ b/client/src/lib/eventAuthoring.js @@ -625,6 +625,152 @@ export function setParam(step, name, raw, type) { */ export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '') +// ── The step editor's own rules (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2) ── +// +// What the controls need to decide and nothing the server re-decides: which +// control a field gets, which required fields are empty, and whether a phase +// gives a timed step back before its time is up. The server still refuses a +// missing param by name — these exist so the form can say it first, in place. + +/** + * A boolean as the control holds it. A lease's value is a string param that the + * lease reads as a boolean, so "true" and "false" are booleans here too. + */ +export function boolValue(value) { + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + return null +} + +/** + * Should this source be drawn as a row of buttons? (§2.4) + * + * Two or three choices that fit on one line, from a source that answered and is + * not searched. A dropdown of two hides the other choice behind a click. + */ +export function isSegmentable(entry) { + return Boolean( + entry && entry.state === 'ok' && !entry.searchable && + entry.options.length >= 2 && entry.options.length <= 3, + ) +} + +/** The note on the chosen option, if it carries one (§2.6). */ +export function noteFor(entry, value) { + if (!entry || entry.state !== 'ok' || value === undefined || value === null || value === '') return null + const chosen = entry.options.find((o) => o.value === String(value)) + return chosen?.note || null +} + +/** + * The required params a step leaves empty, by name. `core.lease`'s `target` is + * optional on the action and required by a targeted LEASE, so the lease is + * asked too when there is one. + */ +export function missingParams(step, action, leasesById = new Map()) { + if (!action) return [] + const parsed = parseParams(step?.paramsText) + if (parsed.error) return [] + const empty = (v) => v === undefined || v === null || v === '' + const missing = (action.params || []).filter((p) => p.required && empty(parsed.params[p.name])).map((p) => p.name) + if (action.id === 'core.lease') { + const lease = leasesById.get(parsed.params.lease) + if (lease?.target && empty(parsed.params.target)) missing.push('target') + } + return missing +} + +/** `30m`, `2h`, `90s`, `1d` → minutes. Null for anything the server would refuse. */ +export function afterMinutes(after) { + const m = /^(\d{1,6})(s|m|h|d)$/.exec(String(after ?? '').trim()) + if (!m) return null + const n = Number(m[1]) + return { s: n / 60, m: n, h: n * 60, d: n * 1440 }[m[2]] +} + +/** The longest declared duration in a step, in minutes; 0 for an untimed one. */ +export function stepMinutes(step, action) { + const parsed = parseParams(step?.paramsText) + if (parsed.error || !action) return 0 + let longest = 0 + for (const p of action.params || []) { + if (p.duration !== 'minutes') continue + const n = Number(parsed.params[p.name]) + if (Number.isFinite(n) && n > longest) longest = n + } + return longest +} + +/** The longest timed step in a phase, in minutes. */ +export const phaseMinutes = (phase, actionById) => + Math.max(0, ...(phase?.steps || []).map((s) => stepMinutes(s, actionById.get(s.actionId)))) + +/** + * How long, at the least, the run goes on from the start of phase `pi`, in + * minutes — or Infinity when a phase from there on waits for something in the + * game, which may take any time at all. + * + * **Why the RUN and not the phase.** Teardown gives everything back when the run + * ends, so a 10-minute hold in phase 1 of three is safe if phases 2 and 3 take + * ten minutes between them. A phase with no advance rule ends when its steps do, + * which is counted as no time: a floor, never a promise. + */ +export function runFloorMinutes(phases, pi) { + let total = 0 + for (const phase of (phases || []).slice(pi)) { + const kind = phase.advance?.kind || '' + if (kind === 'on') return Infinity + if (kind === 'after') total += afterMinutes(phase.advance.after) || 0 + } + return total +} + +/** + * Does phase `pi` give a timed step back before its time? (§2.2, U-6) + * + * `null`, or `{ longest, floor }` in minutes. Only a run that can END sooner than + * the longest hold counts: teardown is what gives a lease back, not the phase. + */ +export function timedPhaseShortfall(phases, pi, actionById) { + const longest = phaseMinutes(phases?.[pi], actionById) + if (!longest) return null + const floor = runFloorMinutes(phases, pi) + return floor < longest ? { longest, floor } : null +} + +/** + * The phase's advance after a timed step was added or changed (§2.2). + * + * A phase with NO advance rule that would give the step back early gets + * `after: 'm'`, visibly, and the author may change it. A phase the + * author already gave a rule keeps it: the warning under the phase says what it + * costs, and overriding an author's choice is not this function's to do. + */ +export function guardedAdvance(phases, pi, actionById) { + const phase = phases?.[pi] + if (!phase || (phase.advance?.kind || '') !== '') return null + const short = timedPhaseShortfall(phases, pi, actionById) + if (!short) return null + return { ...(phase.advance || blankAdvance()), kind: 'after', after: `${Math.ceil(short.longest)}m` } +} + +/** "1 minute", "90 minutes", "2 hours". */ +export function minutesWord(n) { + if (n >= 120 && n % 60 === 0) return `${n / 60} hours` + return `${n} minute${n === 1 ? '' : 's'}` +} + +/** The sentence under a phase that ends a timed step early, or null. */ +export function shortfallSentence(phases, pi, actionById) { + const short = timedPhaseShortfall(phases, pi, actionById) + if (!short) return null + const last = pi === (phases || []).length - 1 + const ends = short.floor === 0 + ? (last ? 'This phase ends as soon as its steps are done, and the run with it' : 'The rest of this run can be over as soon as its steps are done') + : (last ? `This phase ends after ${minutesWord(short.floor)}, and the run with it` : `The rest of this run can be over ${minutesWord(short.floor)} after this phase starts`) + return `${ends}; what is held for ${minutesWord(short.longest)} is given back then, not after ${minutesWord(short.longest)}.` +} + /** * Everything the meter needs out of the form, and nothing else. * diff --git a/client/src/modules/version.js b/client/src/modules/version.js index a4fdab7..8ed171c 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,10 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2): event templates, a +// param's `duration`, a lease's `example` and an option's `note`. All four are +// server-side declarations the editor, core's own page, renders. This file bumps +// for the reason at the top. // 1.12.0 — an event action's `progress()`: a live run's public page shows how // its steps stand. Server-side registration; the client half is core's own event // page rendering the lines. This file bumps for the reason at the top. @@ -81,4 +85,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.12.0' +export const MODULE_API_VERSION = '1.13.0' diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx index d19eac2..ef6fdea 100644 --- a/client/src/routes/admin/views/EventEditor.jsx +++ b/client/src/routes/admin/views/EventEditor.jsx @@ -25,8 +25,14 @@ import { datetimeInputValue, priceBodyFrom, worthPricing, + boolValue, + isSegmentable, + noteFor, + missingParams, + guardedAdvance, + shortfallSentence, } from '../../../lib/eventAuthoring.js' -import { operatorsForType } from '../../../lib/engagementRules.js' +import { operatorsForType, coerceLiteral } from '../../../lib/engagementRules.js' // Admin → Events → the definition editor (EVENTS.md §I, Phase 3). // @@ -215,7 +221,34 @@ function SearchableOptions({ sourceId, label, disabled, onPick }) { } /** - * One declared param, as the control its type implies (Phase 13). + * A row of buttons for a choice of two or three (Rust PLAN_REDESIGNS §2.4). + * + * A dropdown of two hides the other choice behind a click; a row shows both and + * which one is on. `aria-pressed` carries the state for a screen reader, the + * opacity for everyone else — the same convention the weekday buttons use. + */ +function Segmented({ choices, value, disabled, onPick }) { + const current = value === undefined || value === null ? '' : String(value) + return ( +
+ {choices.map((c) => { + const on = current === String(c.value) + return ( + + ) + })} +
+ ) +} + +/** + * One declared param, as the control its type implies (Phase 13; reworked for + * MODULE_API 1.13.0, Rust PLAN_REDESIGNS §2.1). * * **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 @@ -223,32 +256,66 @@ function SearchableOptions({ sourceId, label, disabled, onPick }) { * 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: + * **The field is the thing it does, not its type.** There is no ` · int` tag and + * no bare asterisk (U-3): a required field says so in words, and only when it is + * empty at save time (`missing`). The controls: * - * • **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. + * • **A boolean is a toggle when it is required and three buttons when it is + * optional** (D334). For an OPTIONAL boolean "not set" is a real third state — + * the action's own default — and a two-state control would post `false` for + * every param an author never touched. + * • **A number shows its range and its unit beside it** when the declaration has + * them: a lease's `min`–`max`, a duration's minutes. + * • **A closed set of two or three is a row of buttons**, whether it is a lease's + * `values` or a source that answered with that few (§2.4). + * • **A source-backed param keeps its free-text field** otherwise. 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. A row of buttons replaces the text box only while the source has + * answered and the value is one of its choices. + * • **The chosen option's `note` is shown under the field** (§2.6). + * + * `label`, `range` and `values` are passed by the lease form, which draws one + * lease's fields out of `core.lease`'s generic ones. */ -function ParamField({ param, value, sourceEntry, disabled, onChange }) { +function ParamField({ param, value, sourceEntry, disabled, onChange, missing, label, range, values }) { const common = { className: 'input', disabled, style: { fontSize: '0.8rem' } } const asText = value === undefined || value === null ? '' : String(value) + const placeholder = param.example === undefined || param.example === null ? '' : String(param.example) let control + let beside = null if (param.type === 'boolean') { - control = ( - onChange(e.target.checked ? 'true' : 'false')} /> + + {b === null ? 'Not set yet' : b ? 'Yes' : 'No'} + + + ) : ( + + ) + } else if (values && values.length) { + control = values.length <= 3 ? ( + ({ value: v, label: v }))} /> + ) : ( + ) } else if (param.type === 'datetime') { @@ -266,37 +333,67 @@ function ParamField({ param, value, sourceEntry, disabled, onChange }) { {...common} type="number" step={param.type === 'int' ? '1' : 'any'} + min={range?.min ?? undefined} + max={range?.max ?? undefined} value={asText} - placeholder={param.example === undefined ? '' : String(param.example)} + placeholder={placeholder} + style={{ ...common.style, maxWidth: 180 }} onChange={(e) => onChange(e.target.value)} /> ) + const parts = [] + if (param.duration === 'minutes') parts.push('minutes') + if (range && range.min !== null && range.min !== undefined && range.max !== null && range.max !== undefined) { + parts.push(`${range.min}–${range.max}`) + } else if (range && range.max !== null && range.max !== undefined) { + parts.push(`at most ${range.max}`) + } + if (parts.length) beside = {parts.join(' · ')} } else if (param.type === 'url') { control = ( - onChange(e.target.value)} /> ) } else { control = ( - onChange(e.target.value)} /> ) } + // A source of two or three answered choices is a row of buttons, and the text + // box goes — unless the value is not one of them, which must stay visible and + // editable rather than look unset. + const segmented = param.source && param.type !== 'boolean' && !values && isSegmentable(sourceEntry) + const offList = segmented && asText !== '' && !sourceEntry.options.some((o) => o.value === asText) + const note = param.source ? noteFor(sourceEntry, value) : null + return ( -