feat(events): one lease and the participation verbs (Phase 11b)
The UO half of protocol 6 part b. No route added, no schema change, no
MODULE_API bump.
`uo.playercaps.skillcap` is the one lease, and the catalog is short because
ServUO made it short: of the 158 non-Bridge `Config.Get` call sites in
`Scripts/`, roughly eight are read live. This one is read inside
`CharacterCreation.cs`'s per-character path, so it is both live and observable --
which is what "proven" has to mean, since the failure an allowlist exists to
prevent is a key that applies cleanly and changes nothing.
Its `apply()` sends a DURATION rather than the deadline: an absolute time
computed here and honoured there is measured against two clocks, and a shard
running ten minutes fast would restore a ten-minute lease the instant it took it.
Its `restore()` turns `lease.drifted` into `{ drifted: true, current }` rather
than an error, because core records drift as a distinct successful outcome and an
error would put the row on the retry ladder. Its `inForce()` asks whether the
shard still HOLDS the lease, never whether the value still matches -- see the
core PR.
`uo.participation.open` / `.collect` count who took part and file them on the
success envelope. `open` is the one resource in this module that must NOT
reconcile by boot stamp: every other resource here lives in shard memory, so a
changed bootId IS the proof it is gone, while the participation ledger is written
into the world save precisely so it survives that restart. It asks instead.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,10 @@ function fakeApi() {
|
||||
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
||||
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||
// And the fourth, from Phase 11b. `once` for the same reason, and present here
|
||||
// for a second one: a verb this module calls and this fake does not have is a
|
||||
// TypeError in `entry.test.js` rather than a surprise at somebody's boot.
|
||||
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
|
||||
@@ -61,9 +61,19 @@ test('registers exactly what module.json declares', () => {
|
||||
// anywhere.
|
||||
assert.deepStrictEqual(
|
||||
api.record.eventActions.map((a) => a.id).sort(),
|
||||
['uo.broadcast', 'uo.news.post', 'uo.towncrier.post'],
|
||||
[
|
||||
'uo.broadcast',
|
||||
'uo.news.post',
|
||||
'uo.participation.collect',
|
||||
'uo.participation.open',
|
||||
'uo.towncrier.post',
|
||||
],
|
||||
)
|
||||
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), ['uo.broadcasts'])
|
||||
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
|
||||
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
|
||||
// on any of the rest applies cleanly and does nothing.
|
||||
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), ['uo.playercaps.skillcap'])
|
||||
assert.deepStrictEqual(
|
||||
api.record.eventOptionSources.map((s) => s.id).sort(),
|
||||
['uo.options.creatures', 'uo.options.landmarks', 'uo.options.regions'],
|
||||
|
||||
@@ -42,6 +42,9 @@ beforeEach(() => {
|
||||
uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } }
|
||||
uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } }
|
||||
uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' })
|
||||
// Phase 11b. `uo.participation.open` resolves its `place` param against the
|
||||
// atlas, so the dry-run sweep below reaches this rather than the database.
|
||||
shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
382
server/test/uoEventLeaseParticipation.test.js
Normal file
382
server/test/uoEventLeaseParticipation.test.js
Normal file
@@ -0,0 +1,382 @@
|
||||
// module-uo's half of protocol 6 part b (EVENTS_PLAN.md Phase 11b).
|
||||
//
|
||||
// One lease and two participation verbs. What is worth asserting here is not that
|
||||
// the calls happen — a rig proves that better — but the handful of places where
|
||||
// the obvious implementation is subtly the wrong one, and where nothing would fail
|
||||
// if it were written the other way:
|
||||
//
|
||||
// • a lease's `restore()` must turn `lease.drifted` into `{ drifted: true }`
|
||||
// rather than an error, because core records drift as a distinct SUCCESSFUL
|
||||
// outcome and an error would put the row on the retry ladder instead
|
||||
// • `inForce()` must not be a comparison against `read()` — a changed value is
|
||||
// drift, which teardown reports, and orphaning the row first destroys it
|
||||
// • `apply()` must send a DURATION, not the deadline, or a shard whose clock is
|
||||
// fast restores the lease the instant it takes it
|
||||
// • `uo.participation.open` must NOT reconcile by boot stamp, which every other
|
||||
// resource in this module does — the ledger is persisted in the world save
|
||||
// precisely so that it survives the restart the stamp would report it lost by
|
||||
// • a `userId` is a foreign key and a character serial is not, so an unresolved
|
||||
// one is undefined rather than coerced
|
||||
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
require('./_setup')
|
||||
const actions = require('../config/uoEventActions')
|
||||
|
||||
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
||||
const lease = () => actions.LEASES.find((l) => l.id === 'uo.playercaps.skillcap')
|
||||
|
||||
const STUBBED = [
|
||||
'getLeases',
|
||||
'applyLease',
|
||||
'releaseLease',
|
||||
'openParticipation',
|
||||
'snapshotParticipation',
|
||||
'closeParticipation',
|
||||
]
|
||||
|
||||
let calls
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = { apply: [], release: [], open: [], snapshot: [], close: [] }
|
||||
for (const name of STUBBED) saved[name] = uoLinkClient[name]
|
||||
saved.listLandmarks = shardAtlas.listLandmarks
|
||||
|
||||
uoLinkClient.getLeases = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] },
|
||||
})
|
||||
uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } }
|
||||
uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } }
|
||||
uoLinkClient.openParticipation = async (b) => { calls.open.push(b); return { ok: true, status: 200, data: {} } }
|
||||
uoLinkClient.snapshotParticipation = async (b) => {
|
||||
calls.snapshot.push(b)
|
||||
return { ok: true, status: 200, data: { participants: [] } }
|
||||
}
|
||||
uoLinkClient.closeParticipation = async (b) => { calls.close.push(b); return { ok: true, status: 200, data: {} } }
|
||||
|
||||
shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of STUBBED) uoLinkClient[name] = saved[name]
|
||||
shardAtlas.listLandmarks = saved.listLandmarks
|
||||
})
|
||||
|
||||
// ── The lease ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('the lease satisfies the shape core validates it with', () => {
|
||||
const l = lease()
|
||||
assert.ok(l.id.startsWith('uo.'), 'a lease is namespaced to its module')
|
||||
assert.ok(l.label && l.description)
|
||||
assert.equal(l.type, 'float')
|
||||
// Required for the numeric types, and unlike a cap a bad lease value is in
|
||||
// force the moment it is applied.
|
||||
assert.ok(Number.isFinite(l.min) && Number.isFinite(l.max) && l.min < l.max)
|
||||
assert.ok(Number.isInteger(l.maxDurationMs) && l.maxDurationMs > 0)
|
||||
for (const fn of ['read', 'apply', 'restore', 'inForce']) {
|
||||
assert.equal(typeof l[fn], 'function', `a lease needs ${fn}()`)
|
||||
}
|
||||
})
|
||||
|
||||
test('apply sends a DURATION, because a deadline is measured against two clocks', async () => {
|
||||
const until = new Date(Date.now() + 90 * 60_000)
|
||||
const answer = await lease().apply(1200, until)
|
||||
|
||||
assert.equal(answer.ok, true)
|
||||
const sent = calls.apply[0]
|
||||
// The number the shard arms its timer off. Computed here from the deadline, so
|
||||
// a shard running ten minutes fast holds the lease for ninety minutes of its
|
||||
// own time rather than restoring it the instant it takes it.
|
||||
assert.ok(Math.abs(sent.holdMs - 90 * 60_000) < 2000, `holdMs was ${sent.holdMs}`)
|
||||
// And the absolute time still rides along, for a console that wants to say when
|
||||
// the hold ends in terms the operator's own clock agrees with.
|
||||
assert.equal(sent.untilMs, until.getTime())
|
||||
// The action hands the value on unchanged; `uoLinkClient.applyLease` is what
|
||||
// renders it as TEXT, which is the wire's contract for every lease type: `1200`
|
||||
// and `1200.0` are one number to a JSON parser and two different strings to a
|
||||
// compare-and-set.
|
||||
assert.equal(sent.value, 1200)
|
||||
})
|
||||
|
||||
test('a deadline that has already passed is refused rather than sent as a negative hold', async () => {
|
||||
const answer = await lease().apply(1200, new Date(Date.now() - 60_000))
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.error, /already passed/)
|
||||
assert.equal(calls.apply.length, 0)
|
||||
})
|
||||
|
||||
test('drift comes back as drifted, not as an error', async () => {
|
||||
// The distinction core acts on. `cleanup.js` records `drifted` as its own
|
||||
// outcome — the module did exactly what it was asked and found somebody else's
|
||||
// value in place — while an error would put the row on the retry ladder and
|
||||
// eventually spend its attempts on a situation only a human can resolve.
|
||||
uoLinkClient.releaseLease = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { kind: 'lease.drifted', key: 'PlayerCaps.SkillCap', current: '1300' },
|
||||
})
|
||||
|
||||
const answer = await lease().restore('1000', { expected: '1200' })
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.drifted, true)
|
||||
assert.equal(answer.current, '1300')
|
||||
assert.equal(answer.error, undefined)
|
||||
})
|
||||
|
||||
test('restore sends both what it applied and what to put back', async () => {
|
||||
await lease().restore('1000', { expected: '1200' })
|
||||
// Core's `restore(baseline, { expected })` carries no key of its own -- teardown
|
||||
// is core's own sweep rather than a step dispatch -- so neither does this.
|
||||
assert.deepEqual(calls.release[0], {
|
||||
key: 'PlayerCaps.SkillCap',
|
||||
expected: '1200',
|
||||
baseline: '1000',
|
||||
})
|
||||
})
|
||||
|
||||
test('inForce asks whether the shard still HOLDS it, not whether the value still matches', async () => {
|
||||
// The reason this callable exists at all. A shard reporting a value that is not
|
||||
// what the run applied is reporting DRIFT, which teardown delivers through
|
||||
// `restore()` so the ledger row lands `drifted` with the current value beside
|
||||
// it. Answering "not in force" here would orphan the row first and tell the
|
||||
// operator the lease vanished rather than that somebody moved it.
|
||||
uoLinkClient.getLeases = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1300', held: true }] },
|
||||
})
|
||||
assert.deepEqual(await lease().inForce(), { ok: true, held: true })
|
||||
|
||||
// And a shard that restarted: a config lease is memory-only there by design, so
|
||||
// the value is back at baseline AND the record is gone. This is the case core
|
||||
// could not see before this phase.
|
||||
uoLinkClient.getLeases = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] },
|
||||
})
|
||||
assert.deepEqual(await lease().inForce(), { ok: true, held: false })
|
||||
})
|
||||
|
||||
test('a shard that cannot answer leaves the ledger alone', async () => {
|
||||
uoLinkClient.getLeases = async () => ({ ok: false, status: 503, error: 'shard not connected' })
|
||||
const answer = await lease().inForce()
|
||||
assert.equal(answer.ok, false)
|
||||
// `ok: false` is what core reads as "I could not ask", and it keeps believing
|
||||
// its own ledger. Never `held: false`, which would orphan a live lease the
|
||||
// first time a sidecar was slow.
|
||||
assert.equal(answer.held, undefined)
|
||||
assert.equal((await lease().read()).ok, false)
|
||||
})
|
||||
|
||||
// ── Participation ──────────────────────────────────────────────────────────
|
||||
|
||||
test('open resolves a named place to the point the shard counts around', async () => {
|
||||
const answer = await byId('uo.participation.open').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Britain', radius: 40, durationMinutes: 120 },
|
||||
})
|
||||
|
||||
assert.equal(answer.ok, true)
|
||||
assert.deepEqual(calls.open[0], {
|
||||
runId: 42,
|
||||
map: 'Felucca',
|
||||
x: 1496,
|
||||
y: 1628,
|
||||
radius: 40,
|
||||
holdMs: 7_200_000,
|
||||
idempotencyKey: 'k-1',
|
||||
})
|
||||
assert.deepEqual(answer.resources, [
|
||||
{ kind: 'participation', ref: '42', payload: { runId: 42, place: 'Felucca/Britain', radius: 40 } },
|
||||
])
|
||||
})
|
||||
|
||||
test('a place the atlas does not know is a refusal an author can read, not a retry', async () => {
|
||||
const answer = await byId('uo.participation.open').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Atlantis', radius: 40 },
|
||||
})
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.retry, false)
|
||||
assert.match(answer.error, /no landmark called "Atlantis"/)
|
||||
assert.equal(calls.open.length, 0)
|
||||
})
|
||||
|
||||
test('an area outside the bound is refused before anything is sent', async () => {
|
||||
for (const radius of [0, -1, actions.MAX_AREA_RADIUS + 1, 1.5]) {
|
||||
const answer = await byId('uo.participation.open').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Britain', radius },
|
||||
})
|
||||
assert.equal(answer.ok, false, String(radius))
|
||||
assert.equal(answer.retry, false, String(radius))
|
||||
}
|
||||
assert.equal(calls.open.length, 0)
|
||||
})
|
||||
|
||||
test('a dry run checks the place and the radius and opens nothing', async () => {
|
||||
const good = await byId('uo.participation.open').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Britain', radius: 40 },
|
||||
verify: true,
|
||||
})
|
||||
assert.deepEqual(good, { ok: true })
|
||||
assert.equal(calls.open.length, 0)
|
||||
|
||||
// And it is a real check rather than an unconditional yes: the failure an
|
||||
// author most wants caught before the night of the event is a place that is not
|
||||
// on this shard's map.
|
||||
const bad = await byId('uo.participation.open').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Atlantis', radius: 40 },
|
||||
verify: true,
|
||||
})
|
||||
assert.equal(bad.ok, false)
|
||||
})
|
||||
|
||||
test('the ledger is NOT reconciled by boot stamp, unlike everything else here', async () => {
|
||||
// The phase's one genuine divergence from wave 1. `reconcileByBootId` works
|
||||
// because a crier line and a news article live in shard memory, so a changed
|
||||
// `bootId` IS the proof they are gone. A participation ledger is written into
|
||||
// the world save specifically so that it survives a restart — reporting it lost
|
||||
// on a boot change would orphan the one resource the phase persisted.
|
||||
const open = byId('uo.participation.open')
|
||||
assert.notEqual(open.reconcile, actions.reconcileByBootId)
|
||||
// No stamp on the resource either, so nothing downstream can be tempted to
|
||||
// compare one.
|
||||
const answer = await open.perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Britain', radius: 40 },
|
||||
})
|
||||
assert.equal(answer.resources[0].payload.bootId, undefined)
|
||||
|
||||
// It asks instead, and only an explicit 404 takes a row out.
|
||||
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] })
|
||||
|
||||
uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 404, data: {} })
|
||||
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: [] })
|
||||
|
||||
// A shard that is down has not said the ledger is gone.
|
||||
uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 503, data: {} })
|
||||
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] })
|
||||
})
|
||||
|
||||
test('a run the shard has already forgotten is a successful revert', async () => {
|
||||
// §L: "gone, and that is fine". A shard that restarted past its grace window,
|
||||
// or a second teardown attempt, must not leave a row failing forever.
|
||||
uoLinkClient.closeParticipation = async () => ({ ok: false, status: 404, data: {} })
|
||||
assert.deepEqual(await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }), { ok: true })
|
||||
|
||||
uoLinkClient.closeParticipation = async () => ({ ok: false, status: 503, data: {} })
|
||||
assert.deepEqual(
|
||||
await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }),
|
||||
{ ok: true, failed: ['42'] },
|
||||
)
|
||||
})
|
||||
|
||||
test('collect files the tally as participants, keyed by character serial', async () => {
|
||||
uoLinkClient.snapshotParticipation = async (b) => {
|
||||
calls.snapshot.push(b)
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
participants: [
|
||||
{
|
||||
serial: '0x400150E8',
|
||||
name: 'Darrow',
|
||||
acct: 'seed_001',
|
||||
webId: '17',
|
||||
seconds: 3600,
|
||||
minutes: '60.00',
|
||||
kills: 3,
|
||||
score: '75.0000',
|
||||
firstMs: 1788550182074,
|
||||
},
|
||||
// No account link: the shard reports no webId, and there is nothing to
|
||||
// resolve. Most characters are this one.
|
||||
{
|
||||
serial: '0x1',
|
||||
name: 'Nobody',
|
||||
seconds: 60,
|
||||
minutes: '1.00',
|
||||
kills: 0,
|
||||
score: '1.0000',
|
||||
firstMs: 1788550182074,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||
|
||||
assert.equal(answer.ok, true)
|
||||
assert.equal(calls.snapshot[0].idempotencyKey, 'k-2')
|
||||
assert.deepEqual(answer.participants.map((p) => p.memberKey), ['0x400150E8', '0x1'])
|
||||
// The one field core will not take on trust: it is a foreign key into `users`,
|
||||
// so a serial passed here would either fail the insert or attribute somebody's
|
||||
// attendance to a stranger.
|
||||
assert.equal(answer.participants[0].userId, 17)
|
||||
assert.equal(answer.participants[1].userId, undefined)
|
||||
// The score is opaque to core; the components are carried so a results table
|
||||
// can say why somebody scored what they did.
|
||||
assert.deepEqual(answer.participants[0].meta, {
|
||||
name: 'Darrow', seconds: 3600, minutes: '60.00', kills: 3,
|
||||
})
|
||||
})
|
||||
|
||||
test('a webId that is not a positive integer resolves to nothing at all', () => {
|
||||
for (const bad of [null, undefined, '', 'abc', '0', '-3', '1.5', {}]) {
|
||||
assert.equal(actions.webUserId(bad), undefined, JSON.stringify(bad))
|
||||
}
|
||||
assert.equal(actions.webUserId('17'), 17)
|
||||
assert.equal(actions.webUserId(17), 17)
|
||||
})
|
||||
|
||||
test('a busy shard is retried, because the work is happening', async () => {
|
||||
// 425 is `bridge.busy`: a snapshot of this run is already walking across Core
|
||||
// ticks. Transient by construction, and deliberately not in PERMANENT_STATUSES.
|
||||
uoLinkClient.snapshotParticipation = async () => ({
|
||||
ok: false,
|
||||
status: 425,
|
||||
data: { kind: 'bridge.busy', reason: 'a command under this key is in flight' },
|
||||
})
|
||||
const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.retry, true)
|
||||
|
||||
// Where the event plane simply being switched off is not: 403 is an operator's
|
||||
// deliberate refusal and will still be true in sixty seconds.
|
||||
uoLinkClient.snapshotParticipation = async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
data: { kind: 'participation.error', reason: 'the event plane is disabled on this shard' },
|
||||
})
|
||||
const off = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||
assert.equal(off.retry, false)
|
||||
// And the shard's own words reach the run log, because for an event that ran at
|
||||
// four in the morning that log is the only place anyone will learn why.
|
||||
assert.match(off.error, /event plane is disabled/)
|
||||
})
|
||||
|
||||
test('a dry run of collect reads nothing', async () => {
|
||||
assert.deepEqual(
|
||||
await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2', verify: true }),
|
||||
{ ok: true },
|
||||
)
|
||||
assert.equal(calls.snapshot.length, 0)
|
||||
})
|
||||
Reference in New Issue
Block a user