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

@@ -795,3 +795,196 @@ test('a dry run of core.lease checks everything and takes nothing', async () =>
)
assert.equal(bad.outcome, 'terminal')
})
// ── Targeted leases and searchable sources (Phase 12b) ──────────────────────
test('a targeted lease is a family of values, and the target rides in the ref', async () => {
// The shape §F did not have before this phase. Every lease until now named ONE
// value, so the lease id WAS the target and the callables needed none. An
// object property is one lease over thousands of objects, and the reservation
// has to distinguish them or a run turning up one spawner locks out every other
// run and every other spawner.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
const seen = []
api.registerEventOptionSources([{
id: 'demo.options.seen', label: 'Seen',
async resolve() { return seen.map((s, i) => ({ value: String(i), label: s })) },
}])
api.registerEventLeases([{
id: 'demo.spawner.count',
label: 'Spawner count',
type: 'int', min: 0, max: 50,
maxDurationMs: 3600000,
target: { label: 'Which spawner', source: 'demo.options.seen', example: 'abc' },
async read(a) { seen.push('read:' + JSON.stringify(a)); return { ok: true, value: 3 } },
async apply(v, until, a) { seen.push('apply:' + JSON.stringify(a)); return { ok: true } },
async restore() { return { ok: true } },
}])
}`))
registries.registerCore()
const lease = registries.eventLease('demo.spawner.count')
assert.deepEqual(lease.target, {
label: 'Which spawner',
source: 'demo.options.seen',
example: 'abc',
description: '',
})
// The ref composes, and the parser takes it apart again — the property every
// reader of a ledgered lease depends on.
const ref = registries.leaseRef('demo.spawner.count', '0x40001234')
assert.equal(ref, 'demo.spawner.count#0x40001234')
assert.deepEqual(registries.parseLeaseRef(ref), {
id: 'demo.spawner.count',
target: '0x40001234',
})
assert.equal(registries.eventLeaseForRef(ref).lease.id, 'demo.spawner.count')
assert.equal(registries.eventLeaseForRef(ref).target, '0x40001234')
// An untargeted ref still parses to a bare id, so nothing about the leases that
// existed before this phase changes.
assert.deepEqual(registries.parseLeaseRef('demo.rate'), { id: 'demo.rate', target: null })
})
test('a targeted lease refuses an empty target, and an untargeted one refuses a target', async () => {
// Both are authoring mistakes rather than outages, so both are terminal: the
// second attempt has exactly the same params, and a retry loop against a form
// error is a run that pauses for nothing.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventLeases([
{
id: 'demo.spawner.count', label: 'Spawner count', type: 'int', min: 0, max: 50,
maxDurationMs: 3600000,
target: { label: 'Which spawner' },
async read() { return { ok: true, value: 3 } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
},
{
id: 'demo.rate.gain', label: 'Gain rate', type: 'float', min: 0.5, max: 5,
maxDurationMs: 3600000,
async read() { return { ok: true, value: 1 } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
},
])
}`))
registries.registerCore()
const noTarget = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.spawner.count', value: '9', minutes: 10 }),
{ run: RUN, verify: true },
)
assert.equal(noTarget.outcome, 'terminal')
assert.match(noTarget.error, /needs a which spawner/i)
const spuriousTarget = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 10, target: '0x1' }),
{ run: RUN, verify: true },
)
assert.equal(spuriousTarget.outcome, 'terminal')
assert.match(spuriousTarget.error, /takes no target/)
})
test('a string lease with a declared value set is bounded at authoring time', async () => {
// `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 that arrives
// unattended, mid-run. The set closes that at the form.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventLeases([{
id: 'demo.season.status', label: 'Seasonal status', type: 'string',
values: ['Inactive', 'Active', 'Seasonal'],
maxDurationMs: 3600000,
target: { label: 'Which event' },
async read() { return { ok: true, value: 'Inactive' } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
}])
}`))
registries.registerCore()
const bad = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.season.status', value: 'On', minutes: 10, target: 'Fellowship' }),
{ run: RUN, verify: true },
)
assert.equal(bad.outcome, 'terminal')
assert.match(bad.error, /Inactive, Active, Seasonal/)
const good = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.season.status', value: 'Active', minutes: 10, target: 'Fellowship' }),
{ run: RUN, verify: true },
)
assert.equal(good.outcome, 'done')
})
test('a values list belongs to a string lease and to no other type', () => {
// Closed like `type` itself, and for the same reason: a set on an int lease
// would be a second bound beside `min`/`max` with no rule about which wins.
const api = registries.stage('demo')
assert.throws(
() => api.registerEventLeases([{
id: 'demo.n', label: 'N', type: 'int', min: 0, max: 5, values: ['1', '2'],
maxDurationMs: 1000,
read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }),
}]),
/values is for string leases/,
)
assert.throws(
() => api.registerEventLeases([{
id: 'demo.s', label: 'S', type: 'string', values: [],
maxDurationMs: 1000,
read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }),
}]),
/non-empty array/,
)
assert.throws(
() => api.registerEventLeases([{
id: 'demo.t', label: 'T', type: 'bool', target: { source: 'nope' },
maxDurationMs: 1000,
read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }),
}]),
/target has no label/,
)
})
test('a source is passed the search term whether or not it reads one', async () => {
// Additive on purpose: `resolve({ q })` reaches every source, and one that
// ignores the argument answers exactly as it did before this existed. Only the
// sources too large for a dropdown have to care, and `searchable` is what says
// which those are — inferring it from a truncated answer would read correctly
// right up until a small shard's list happens to fit.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventOptionSources([
{
id: 'demo.options.big', label: 'Big', searchable: true,
async resolve({ q } = {}) {
const all = ['alpha', 'beta', 'gamma']
const hits = q ? all.filter((n) => n.includes(q)) : all
return hits.map((n) => ({ value: n, label: n }))
},
},
{ id: 'demo.options.small', label: 'Small', async resolve() { return [{ value: 'only' }] } },
])
}`))
const all = await registries.resolveOptionSource('demo.options.big')
assert.deepEqual(all.options.map((o) => o.value), ['alpha', 'beta', 'gamma'])
assert.equal(all.searchable, true)
const narrowed = await registries.resolveOptionSource('demo.options.big', { q: 'et' })
assert.deepEqual(narrowed.options.map((o) => o.value), ['beta'])
assert.equal(narrowed.q, 'et')
// The source that ignores it is unchanged, and says it is not searchable so the
// form renders a select rather than a box that appears to filter and does not.
const small = await registries.resolveOptionSource('demo.options.small', { q: 'anything' })
assert.deepEqual(small.options, [{ value: 'only', label: 'only' }])
assert.equal(small.searchable, false)
// Bounded rather than refused: a term this long is a paste, and truncating it
// still answers something where refusing would blank the form mid-keystroke.
const long = await registries.resolveOptionSource('demo.options.big', { q: 'x'.repeat(400) })
assert.equal(long.ok, true)
assert.equal(long.q.length, 120)
})