Files
Module-uo/server/test/uoEventLeaseParticipation.test.js
wtclaude 88bfe9310e
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 40s
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>
2026-09-04 19:31:57 -05:00

383 lines
16 KiB
JavaScript

// 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)
})