feat(events): the authoring UI proper (Phase 13)
Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.
One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.
A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.
Two defects fixed in already-merged code:
* Creating an event has been impossible since Phase 6. `events/new` was added
beside `events/:id` and binds no param, and React Router ranks a static
segment above a dynamic one whatever the order -- so the editor was handed no
id and fetched /admin/events/undefined. Worse, the failure was invisible:
`!form` is true for every failed load, so the error state sat behind a
spinner that never stopped.
* 12b's searchable sources had no consumer. The server half shipped and the
only UI that reads a source never sent a term, so the 6,707-entry spawner
list was picked from a 2,000-entry truncation with nothing saying so.
Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -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