feat(events): targeted leases, value sets and searchable sources (Phase 12b)
The core half of Phase 12b, and the half Phase 12a did not need. A targeted
lease is a shape `core.lease` did not have.
Every lease before this named a SINGLE value, so the lease id WAS the target and
none of the four callables took one. `Spawner.MaxCount` is not that shape: it is
one capability over thousands of spawners, and a reservation on the id alone
would let one run turning up one spawner refuse every other run every other
spawner. So a lease may declare a `target`, the callables are handed it, and the
ledger ref becomes `<lease id>#<target>` -- which puts the two-events-one-target
refusal at the granularity the world actually has while leaving it coming from
the same unique index it always did.
Extending core rather than giving the module a lease verb of its own is what §F
decided in Phase 8 ("the verb is core's"): a lease verb per module would
re-implement `maxDurationMs` and the conflict check once per module, advisory
everywhere and wrong in the first one that forgot. Half that objection no longer
holds -- the target check comes free from the index whichever verb reserves the
row -- and the other half still does.
Three readers of a lease ref, not one. `cleanup.restoreLease` and
`ledger.normalise` both looked a lease up by the whole `row.ref`, and both were
correct for exactly as long as a ref was a bare id. Left alone, a targeted row
would have missed in both -- cleanup reporting "no module registers the lease"
and refusing to restore a world that really was changed, which is the worst
failure this table has. All three now go through `eventLeaseForRef`.
`values` closes a `string` lease's set. `min`/`max` bound the numeric types and
nothing bounded `string`, so the only check on a string lease's value was the
game side's -- a refusal arriving unattended, mid-run, from a step nobody is
watching. Refused on any other type: a set beside `min`/`max` would be a second
bound with no rule about which wins.
Option sources become searchable, and the first one that needed it forced this
phase's shape. `resolveOptionSource(id)` took no argument and every source
answered a flat list bounded at 2,000; module-uo's spawner target is 6,707 spawn
points, so a flat list would have dropped two thirds of the world and said
nothing about which two thirds -- the failure 12a named for decoration, arriving
for real. `resolve({ q })` is additive: every source is passed a term, none is
required to read one, and a `searchable` flag says which do, because inferring it
from a truncated answer reads correctly right up until a small deployment's list
happens to fit.
`MODULE_API_VERSION` stays 1.10.0, amended IN PLACE (org lead, 2026-09-07) -- the
shape every phase since P10 has used while this workstream sits on `edge`.
The swagger regeneration carries one incidental change: the committed spec said
the session cookie is `rg_rig`, which is neither the documented default nor what
this repo's own `server/.env` sets. It was generated somewhere with that env var
set. The regeneration corrects it to `rg_token`.
2010 pass, 0 fail (89 DB-skipped), with `modules/uo` parked as the core suite
requires. Six new tests cover the targeted-lease shape, both refusal directions,
the value set, and the search term.
Refs: docs/link/v7.md §11, docs/website/MODULE_API.md, EVENTS_PLAN.md Phase 12b
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -488,10 +488,49 @@ const allEventLeases = () =>
|
||||
/** One lease, callables included. `core.lease` and the cleanup sweep read it. */
|
||||
const eventLease = (id) => eventLeases.get(id) || null
|
||||
|
||||
// How a targeted lease's reservation ref is written, and the one place that
|
||||
// knows it. `#` is safe as the separator because `EVENT_ID` admits only
|
||||
// `[a-z0-9_.]`, so no lease id can contain one and the split is unambiguous
|
||||
// however odd a module's target string is.
|
||||
const LEASE_TARGET_SEP = '#'
|
||||
|
||||
/** Compose the ref an `override` row is reserved under. */
|
||||
const leaseRef = (id, target) =>
|
||||
target === null || target === undefined || target === '' ? id : `${id}${LEASE_TARGET_SEP}${target}`
|
||||
|
||||
/**
|
||||
* Split an `override` row's ref back into the lease and the target it names.
|
||||
*
|
||||
* **Every reader of a ledgered lease needs this, not just the one that wrote
|
||||
* it.** `cleanup.restoreLease` and `ledger.normalise` both look a lease up by
|
||||
* `row.ref`, and both were correct for exactly as long as a ref was a bare lease
|
||||
* id. A targeted row would have missed in both — cleanup reporting a lease no
|
||||
* module registers and refusing to restore a world that really was changed,
|
||||
* which is the worst failure this table has.
|
||||
*/
|
||||
function parseLeaseRef(ref) {
|
||||
const text = String(ref === undefined || ref === null ? '' : ref)
|
||||
const at = text.indexOf(LEASE_TARGET_SEP)
|
||||
if (at < 0) return { id: text, target: null }
|
||||
return { id: text.slice(0, at), target: text.slice(at + 1) }
|
||||
}
|
||||
|
||||
/** The lease an `override` row names, target included. Null when nothing registers it. */
|
||||
function eventLeaseForRef(ref) {
|
||||
const { id, target } = parseLeaseRef(ref)
|
||||
const lease = eventLeases.get(id) || null
|
||||
return lease ? { lease, target } : null
|
||||
}
|
||||
|
||||
/** Every option source WITHOUT its resolver — the authoring form's list. */
|
||||
const allEventOptionSources = () =>
|
||||
[...eventOptionSources.values()].map(({ resolve, ...rest }) => rest)
|
||||
|
||||
// The longest search term a source is asked to honour. Bounded rather than
|
||||
// refused: a term this long is a paste rather than a search, and truncating it
|
||||
// still answers something, where refusing would blank a form mid-keystroke.
|
||||
const MAX_OPTION_QUERY = 120
|
||||
|
||||
/**
|
||||
* Resolve one option source, or say why not. Never throws.
|
||||
*
|
||||
@@ -509,12 +548,25 @@ const allEventOptionSources = () =>
|
||||
* rather than to nothing — a dropdown of blank rows is a worse field than the
|
||||
* text box it replaced.
|
||||
*/
|
||||
async function resolveOptionSource(id) {
|
||||
async function resolveOptionSource(id, { q = '' } = {}) {
|
||||
const entry = eventOptionSources.get(id)
|
||||
if (!entry) return { ok: false, reason: `no module registers the option source "${id}"` }
|
||||
// **The search term is passed to every source and required of none** (Phase
|
||||
// 12b). A source answers a list; one whose catalog is larger than a dropdown
|
||||
// can hold answers a list NARROWED BY a term, and the two are the same
|
||||
// callable because the difference is the source's business rather than the
|
||||
// form's. A resolver that ignores the argument behaves exactly as it did
|
||||
// before this existed, which is what made this additive.
|
||||
//
|
||||
// It exists because the first source with more entries than `MAX_OPTIONS` is
|
||||
// Phase 12b's spawner target — 6,707 spawn points against a 2,000 bound. Every
|
||||
// source before it fitted, so the bound had only ever truncated in theory; a
|
||||
// dropdown quietly missing two thirds of the world is the same failure 12a
|
||||
// named for decoration, and truncation cannot be the answer to it.
|
||||
const term = String(q || '').trim().slice(0, MAX_OPTION_QUERY)
|
||||
let raw
|
||||
try {
|
||||
raw = await entry.resolve()
|
||||
raw = await entry.resolve({ q: term })
|
||||
} catch (err) {
|
||||
log.error('option source resolver failed', {
|
||||
source: id,
|
||||
@@ -534,7 +586,11 @@ async function resolveOptionSource(id) {
|
||||
if (o.group) option.group = String(o.group)
|
||||
options.push(option)
|
||||
}
|
||||
return { ok: true, id, label: entry.label, owner: entry.owner, options }
|
||||
// `searchable` is reported so the authoring form knows which field is a
|
||||
// typeahead and which is a plain select. Without it P13 would have to infer it
|
||||
// from whether a truncated list came back, which is a guess that reads
|
||||
// correctly right up until a small shard's spawner list fits.
|
||||
return { ok: true, id, label: entry.label, owner: entry.owner, searchable: entry.searchable, options, q: term }
|
||||
}
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
@@ -1212,6 +1268,61 @@ function checkEventLeaseShape(entry) {
|
||||
throw new Error(`registerEventLeases: ${l.id} inForce must be a function`)
|
||||
}
|
||||
|
||||
// **A TARGETED lease is a family of values rather than one** (Phase 12b).
|
||||
//
|
||||
// Every lease before this one named a single value — a config key, a rate —
|
||||
// and the lease id WAS the target, which is why the four callables took none
|
||||
// and why the reservation ref was the id alone. An object property is not that
|
||||
// shape: `Spawner.MaxCount` is one lease over thousands of spawners, and two
|
||||
// runs turning up two DIFFERENT spawners must both be allowed while two runs
|
||||
// turning up the same one must not.
|
||||
//
|
||||
// So a lease may declare a target, and when it does core adds one to the
|
||||
// reservation ref (`<id>#<target>`) and hands it to every callable. The
|
||||
// two-events-one-target refusal then comes from the same unique index it
|
||||
// always did, at the granularity the world actually has. **Core still owns the
|
||||
// duration bound and the conflict check** — which is the whole of why §F put
|
||||
// the verb in core, and the reason this is an extension of `core.lease` rather
|
||||
// than a lease verb of the module's own.
|
||||
let target = null
|
||||
if (l.target !== undefined && l.target !== null) {
|
||||
const t = l.target
|
||||
if (typeof t !== 'object' || Array.isArray(t)) {
|
||||
throw new Error(`registerEventLeases: ${l.id} target must be an object`)
|
||||
}
|
||||
if (!t.label) throw new Error(`registerEventLeases: ${l.id} target has no label`)
|
||||
// The source is checked for shape only, never for existence, on
|
||||
// `resolveOptionSource`'s own argument: a source that cannot answer degrades
|
||||
// its field to free text rather than blocking the form, and a source
|
||||
// registered by a module that boots later must not make this one throw.
|
||||
if (t.source !== undefined && !OPTION_SOURCE_ID.test(t.source || '')) {
|
||||
throw new Error(`registerEventLeases: ${l.id} target names a bad option source "${t.source}"`)
|
||||
}
|
||||
target = {
|
||||
label: String(t.label),
|
||||
source: t.source || null,
|
||||
example: t.example === undefined ? null : String(t.example),
|
||||
description: t.description || '',
|
||||
}
|
||||
}
|
||||
|
||||
// **A closed set of values, for the type that has no range** (Phase 12b).
|
||||
// `min`/`max` bound the numeric types and nothing bounded `string`, so a string
|
||||
// lease was free text validated only by the game side — which means an invalid
|
||||
// value is a refusal at DISPATCH, unattended, halfway through a run, instead of
|
||||
// a refusal on the form. Optional, because a genuinely free-text lease still
|
||||
// exists; enforced by `coerceLeaseValue` when present.
|
||||
let values = null
|
||||
if (l.values !== undefined && l.values !== null) {
|
||||
if (!Array.isArray(l.values) || !l.values.length) {
|
||||
throw new Error(`registerEventLeases: ${l.id} values must be a non-empty array`)
|
||||
}
|
||||
if (l.type !== 'string') {
|
||||
throw new Error(`registerEventLeases: ${l.id} is ${l.type}; values is for string leases`)
|
||||
}
|
||||
values = l.values.map((v) => String(v))
|
||||
}
|
||||
|
||||
return {
|
||||
id: l.id,
|
||||
label: l.label,
|
||||
@@ -1219,6 +1330,8 @@ function checkEventLeaseShape(entry) {
|
||||
type: l.type,
|
||||
min,
|
||||
max,
|
||||
values,
|
||||
target,
|
||||
maxDurationMs,
|
||||
read: l.read,
|
||||
apply: l.apply,
|
||||
@@ -1250,7 +1363,18 @@ function checkEventOptionSourceShape(entry) {
|
||||
if (typeof s.resolve !== 'function') {
|
||||
throw new Error(`registerEventOptionSources: ${s.id} has no resolve()`)
|
||||
}
|
||||
return { id: s.id, label: s.label, description: s.description || '', resolve: s.resolve }
|
||||
// **A declaration, not an inference** (Phase 12b). `resolve({ q })` is passed a
|
||||
// term whether or not the source reads it, so nothing here can tell from the
|
||||
// callable alone whether a term would narrow the answer. The module says, and
|
||||
// the authoring form renders a typeahead rather than a select for the ones
|
||||
// that do.
|
||||
return {
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
description: s.description || '',
|
||||
searchable: s.searchable === true,
|
||||
resolve: s.resolve,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1898,6 +2022,9 @@ module.exports = {
|
||||
isEventBudget,
|
||||
allEventLeases,
|
||||
eventLease,
|
||||
eventLeaseForRef,
|
||||
parseLeaseRef,
|
||||
leaseRef,
|
||||
allEventOptionSources,
|
||||
resolveOptionSource,
|
||||
allEngagementSeeds,
|
||||
|
||||
Reference in New Issue
Block a user