feat(events): targeted leases, value sets and searchable sources (Phase 12b)
Some checks failed
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Failing after 5m46s
PR Checks / bot-tests (pull_request) Successful in 8m28s

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:
2026-09-07 08:06:37 -05:00
parent d0178c6419
commit 37f4623068
9 changed files with 512 additions and 21 deletions

View File

@@ -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,